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()
  • pythonista_als_to_midifile_converter.txt
  • Last modified: 2020/04/22 18:40
  • by MrBlaschke