# 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()