Pythonista: Ableton Live Set (ALS) to MIDI converter script
This Pythonista script installs a share extension to convert Ableton Live Set export files into MIDI files containing the notes of the exported tracks.
How to install:
- First you need to install a newer version of the midiutil
- Goto the midiutils official github webpage and press the RAW button to open the source.
- 'Select all' is currently broken in Safarai for IOS 13, so you need to double-tap to start a selection and move the first selection marker to the start of the text and then the second selection marker to the bottom of this about 2000lines long text. (Thank Apple for this inconvience). Then select copy to copy the whole source code to the clipboard.
- Another approach is to use the Readle Documents browser that allows to download the file to and then open that file to get a 'Select All' and 'Copy' action
- Open Pythonista and create a new file using the + button, choose 'Emtpy script'
- In the following dialog enter the name midiutil_v1_2_1.py exactly, select site-package-3 as output folder and press 'Create'
- Paste the clipboard, the content should be 1836 lines long.
- After installing the above file, either
- Download the python script by using the button above the code, long press the file in the files app, select share, choose 'Run Pythonista Script' and then 'Import File'
- or
- Copy the script code block, open Pythonista, create a new file named ALS_to_MIDI.py and paste the clipboard
- In Pythonista settings/App Extensions select 'Share Extension Shortcuts'
- Use the + sign to add a extension
- Select the downloaded python script
- Set the custom title: ALS to MIDI
- Select 'Primaries_Expand' as icon
- Select a pleasing icon color
How to use:
- In the files app, long press the exported ALS file you want to convert
- In the options popup, select share
- In the share popup, select 'Run Pythonista Script' and then select the 'ALS to MIDI' extension
- ALS_to_MIDI.py
# Ableton MIDI clip zip export to MIDI file converter # Original script by MrBlaschke # Usability enhancements by rs2000 # Dec 11, 2019, V.04 # # greatly enhanced version that handles multiple scenes and clip offsets # resulted in new parser engine # request by @SpookyZoo # # Original request and idea by Svetlovska import sys import os import tempfile import xml.etree.ElementTree as ET import xml.etree as XTree from xml.etree.ElementTree import fromstring, ElementTree import console import io import appex import ui from zipfile import ZipFile from zipfile import BadZipfile import gzip import binascii from time import sleep #custom (newer) version - ahead of the Pythonista version #get the code from: https://github.com/MarkCWirt/MIDIUtil/blob/develop/src/midiutil/MidiFile.py #switch to the "RAW" mode and copy all you see on that big text-page #place it in the "Python Modules/site-packages-3" directory #in a new file called "midiutil_v1_2_1.py" from midiutil_v1_2_1 import MIDIFile def main(): if not appex.is_running_extension(): print('This script is intended to be run from the sharing extension.') return # Catch zip file from external "Open in..." dialog inputFile = appex.get_file_path() outfile = os.path.splitext(os.path.basename(inputFile))[0] + ".mid" targetCC = -1 #some global cleverness - digital post-it's haveZIP = False haveGadget = False try: with ZipFile(inputFile) as zf: print("Info: we have a real ZIP archive") haveZIP = True except BadZipfile: print("Info: It is an ALS or Gadget file") if inputFile.endswith(".zip") and haveZIP == True: print("Importing ZIP archive...") with ZipFile(inputFile, 'r') as ablezip: # Iterate over the list of file names in given archive # filter out possible hidden files in "__MACOSX" directories for manually created ZIPs, etc listOfiles = ablezip.namelist() for elem in listOfiles: if not elem.startswith("__") and elem.endswith(".als"): #print('Found:', elem, end=' ') infile = ablezip.extract(elem) elif inputFile.endswith(".als"): infile = inputFile with open(infile, 'rb') as test_f: #Is true if file is gzip if binascii.hexlify(test_f.read(2)) == b'1f8b': print("Input is Gadget ALS file") haveGadget = True with gzip.open(inputFile, 'rb') as f: gadgetContents = f.read().decode("utf-8") else: print("Input is plain ALS file") else: print("filetype not supported...") sys.exit() track = 0 channel = 0 time = 0 # In beats duration = 1 # In beats tempo = 60 # In BPM volume = 100 # 0-127, as per the MIDI standard toffset = 0 # for calculating time-offsets in multi scenes timeoff = 0 # store for temp offsets #Parse the data/file because parsing strings will not clean up bad characters in XML if haveGadget == True: #some people need always special treatment - handle them with care... tree = ElementTree(fromstring(gadgetContents)) else: tree = ET.parse(str(infile)) root = tree.getroot() #getting the tempo/bpm (rounded) from the Ableton file for master in root.iter('Tempo'): for child in master.iter('FloatEvent'): tempo = int(float(child.get('Value'))) #get amount of tracks to be allocated for tracks in root.iter('Tracks'): numTracks = len(list(tracks.findall('MidiTrack'))) print('Found',str(numTracks),'track(s) with', tempo, 'BPM') #Preparing the target MIDI-file MyMIDI = MIDIFile(numTracks, adjust_origin=True) #tempo track is created automatically MyMIDI.addTempo(track, time, tempo) #Give me aaaallll you've got for miditrack in root.findall('.//MidiTrack'): #resetting the time offset data toffset = 0 timeoff = 0 #getting track data (name, etc) for uname in miditrack.findall('.//UserName'): trackname = uname.attrib.get('Value') print('\nProcessing track: ', trackname) MyMIDI.addTrackName(track, 0, trackname) for clipslot in miditrack.findall('.//MainSequencer/ClipSlotList/ClipSlot'): #looping the amount of clips for midiclip in clipslot.findall('.//ClipSlot/Value/MidiClip'): #raising the time offset for the next clip inside this track toffset = toffset + timeoff #get the clip-length for loopinfo in midiclip.findall('.//Loop'): le = loopinfo.find('LoopEnd') #store the next time offset timeoff = float(le.attrib.get('Value')) for noteinfo in midiclip.findall('.//Notes/KeyTracks'): print('\tAmount of note events: ', len(noteinfo.getchildren())) for keytracks in noteinfo: for key in keytracks.findall('.//MidiKey'): keyt = int(key.attrib.get('Value')) print('\t\tProcessing key: ', str(keyt)) #getting the notes for notes in keytracks.findall('.//Notes/MidiNoteEvent'): tim = float(notes.attrib.get('Time')) + float(toffset) dur = float(notes.attrib.get('Duration')) vel = int(notes.attrib.get('Velocity')) MyMIDI.addNote(track, channel, keyt, tim, dur, vel) #getting automation data for envelopes in midiclip.findall('.//Envelopes/Envelopes'): for clipenv in envelopes: #get the automation internal id autoid = int(clipenv.find('.//EnvelopeTarget/PointeeId').attrib.get('Value')) if autoid == 16200: #pitchbend targetCC = 0 print('\tFound CC-data for: Pitch') elif autoid == 16203: #mod-wheel targetCC = 1 print('\tFound CC-data for: Modulation') elif autoid == 16111: #cutoff? targetCC = 74 print('\tFound CC-data for: Cutoff') else: targetCC = -1 print('\n!! Found unhandled CC data. Contact developer for integration. Thanks!') #get the automation values for each envelope for automs in clipenv.findall('.//Automation/Events'): for aevents in automs: eventvals = aevents.attrib ccTim = float(eventvals.get('Time')) ccVal = int(eventvals.get('Value')) if ccTim < 0: ccTim = 0 #writing pitchbend informations if targetCC == 0: MyMIDI.addPitchWheelEvent(track, channel, ccTim, ccVal) #writing other CC values if targetCC != -1 and targetCC != 0: MyMIDI.addControllerEvent(track, channel, ccTim, targetCC, ccVal) track = track + 1 with tempfile.NamedTemporaryFile(suffix='.mid') as fp: MyMIDI.writeFile(fp) fp.seek(0) fp.read() # Open the MIDI file in your app of choice - aka 'bring out the gimp' console.open_in(str(fp.name)) #closing and deleting the temporary file fp.close() print ('done.') if __name__ == '__main__': main()