Monday, April 12, 2010

Introduction to the Framework Classes Part 2

Background

In this post, we’ll have a look at the differences between Live 7 and Live 8 remote scripts, get the Max for Live point-of-view on control surfaces, take a detailed look at the newest APC40 (and APC20) scripts – and demonstrate a few APC script hacks along the way. If you’re new to MIDI remote scripts, you might want to have a look at Part 1 of the Introduction to the Framework Classes before coming back here for Part 2. 

Keeping up with recent changes

As discussed previously, most of what we know about MIDI remote scripting has been based on exploring decompiled Python pyc files. The Live 7 remote scripts are Python 2.2 files, and have proven to be relatively easy to decompile. Live 8’s integration of Python 2.5, on the other hand, presents a new challenge. At present, there is no reliable, freely accessible method for decompiling 2.5 pyc files. It is possible, however, to get a good sense of the latest changes made to the MIDI remote scripts using the tools at hand.

Unpyc is one such tool, and unpyc can decompile python 2.5 files - but only up to a point. In most cases, it will only produce partial source code, but at least it lets us know where it is having trouble. It can, however, disassemble 2.5 files without fail. When we’re armed with a partial decompile, and a complete disassembly, it is possible to reconstruct working script source code - although the process is tedious at the best of times. But even without reconstructing complete sources, Unpyc allows us to take a peek behind the scenes and understand the nature of the changes implemented with the most recent MIDI remote scripts.

As an example, here is the mixer setup method from the APC40.py script - Live 8.1.1 version:

def _setup_mixer_control(self):
        is_momentary = True
        mixer = SpecialMixerComponent(8)
        mixer.name = 'Mixer'
        mixer.master_strip().name = 'Master_Channel_Strip'
        mixer.selected_strip().name = 'Selected_Channel_Strip'
        for track in range(8):
            strip = mixer.channel_strip(track)
            strip.name = 'Channel_Strip_' + str(track)
            volume_control = SliderElement(MIDI_CC_TYPE, track, 7)
            arm_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 48)
            solo_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 49)
            mute_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 50)
            select_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 51)
            volume_control.name = str(track) + '_Volume_Control'
            arm_button.name = str(track) + '_Arm_Button'
            solo_button.name = str(track) + '_Solo_Button'
            mute_button.name = str(track) + '_Mute_Button'
            select_button.name = str(track) + '_Select_Button'
            strip.set_volume_control(volume_control)
            strip.set_arm_button(arm_button)
            strip.set_solo_button(solo_button)
            strip.set_mute_button(mute_button)
            strip.set_select_button(select_button)
            strip.set_shift_button(self._shift_button)
            strip.set_invert_mute_feedback(True)
        crossfader = SliderElement(MIDI_CC_TYPE, 0, 15)
        master_volume_control = SliderElement(MIDI_CC_TYPE, 0, 14)
        master_select_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 80)
        prehear_control = EncoderElement(MIDI_CC_TYPE, 0, 47, Live.MidiMap.MapMode.relative_two_compliment)
        crossfader.name = 'Crossfader'
        master_volume_control.name = 'Master_Volume_Control'
        master_select_button.name = 'Master_Select_Button'
        prehear_control.name = 'Prehear_Volume_Control'
        mixer.set_crossfader_control(crossfader)
        mixer.set_prehear_volume_control(prehear_control)
        mixer.master_strip().set_volume_control(master_volume_control)
        mixer.master_strip().set_select_button(master_select_button)
        return mixer

Compare the above with the equivalent code from the 7.0.18 version:

def _setup_mixer_control(self):
        is_momentary = True
        mixer = MixerComponent(8)
        for track in range(8):
            strip = mixer.channel_strip(track)
            strip.set_volume_control(SliderElement(MIDI_CC_TYPE, track, 7))
            strip.set_arm_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 48))
            strip.set_solo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 49))
            strip.set_mute_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 50))
            strip.set_select_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, track, 51))
            strip.set_shift_button(self._shift_button)
            strip.set_invert_mute_feedback(True)
        mixer.set_crossfader_control(SliderElement(MIDI_CC_TYPE, 0, 15))
        mixer.set_prehear_volume_control(EncoderElement(MIDI_CC_TYPE, 0, 47, Live.MidiMap.MapMode.relative_two_compliment))
        mixer.master_strip().set_volume_control(SliderElement(MIDI_CC_TYPE, 0, 14))
        mixer.master_strip().set_select_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 80))
        return mixer

As we can see, the main difference between the Live 7.0.18 code and the Live 8.1.1 code is that in the new script, name attributes have been assigned to most of the components and elements. So who needs names? Max for Live needs names.

Max for Live – keeping up with Cycling ‘74

Max for Live requires Live 8.1 or higher, and, accordingly, new Live 8 versions include Max-compatible MIDI remote scripts. The name attributes assigned to the python objects and methods allow Max for Live to see and access python methods and objects more easily (objects including control surfaces, components, and control elements). This can be verified with the help of Max for Live’s LiveAPI resource patches (M4L.api.SelectComponent, M4L.api.SelectControlSurface, M4L.api.SelectControl, etc.). Before we demonstrate, however, let’s have a look at how Control Surfaces fit into the world of Max’s Live Object Model (LOM).

The three main root objects in the Live Object Model are known as live_app (Application), live_set (Song), and control_surfaces (Control Surface). Cycling ‘74 provides somewhat detailed documentation for the first two, however, the last one seems to have been left out - intentionally, no doubt. Based on the LOM diagram, we can see that the Control Surfaces root object includes classes for components and controls. These map directly to python Framework modules (modules with names like ControlSurface, TransportComponent, SessionComponent, ButtonElement, etc.). Now, although documentation which explains this is almost non-existent, it turns out that the python methods and objects of the Framework classes are both visible and accessible from Max for Live. The remote script methods appear to Max as functions – functions which can be called using the syntax and arguments defined in the Python scripts.

It is now clear that in order to gain a proper understanding of how to manipulate more complicated Control Surfaces, a study of the python remote scripts is essential - because that's where the Control Surface functions originate. We'll be demonstrating this link between Max for Live and the python scripts, but before we do, let’s take a peek under the hood of what has proven to be a very popular Live controller - the APC40 (now almost a year old).

The APC40 – under the hood

The latest Live 7 versions include MIDI remote scripts for the APC40, and, of course, Live 8.1 and higher also support the APC40. As mentioned above, the Live 8.1 versions of the APC40 scripts show slight differences - generally, name attributes have been added to most of the methods and objects. We’ll base our investigation here on the 8.1.1 scripts, in an effort to stay somewhat current.

There are 11 files in the APC40 MIDI remote scripts directory:

__init__.pyc
APC40.pyc
DetailViewControllerComponent.pyc
EncoderMixerModeSelectorComponent.pyc
PedaledSessionComponent.pyc
ShiftableDeviceComponent.pyc
ShiftableTranslatorComponent.pyc
ShiftableTransportComponent.pyc
SpecialChannelStripComponent.pyc
SpecialMixerComponent.pyc
RingedEncoderElement.pyc

The __init__.py script is rather uninteresting, as it is with most scripts:

import Live
from APC40 import APC40
def create_instance(c_instance):
    """ Creates and returns the APC40 script """
    return APC40(c_instance)

This is a standard init, as shown in Part 1 of the Introduction to Remote Scripts. Now, the script files here with the longish names are special classes, which inherit from Framework modules, but add custom functionality. What they do (and the name of the Framework classes they inherit from) is described in the Docstrings of the scripts themselves:

class DetailViewControllerComponent(ControlSurfaceComponent):
    ' Component that can toggle the device chain- and clip view of the selected track '
class EncoderMixerModeSelectorComponent(ModeSelectorComponent):
    ' Class that reassigns encoders on the AxiomPro to different mixer functions '
class PedaledSessionComponent(SessionComponent):
    ' Special SessionComponent with a button (pedal) to fire the selected clip slot '
class RingedEncoderElement(EncoderElement):
    ' Class representing a continuous control on the controller enclosed with an LED ring '
class ShiftableDeviceComponent(DeviceComponent):
    ' DeviceComponent that only uses bank buttons if a shift button is pressed '
class ShiftableTranslatorComponent(ChannelTranslationSelector):
    ' Class that translates the channel of some buttons as long as a shift button is held '
class ShiftableTransportComponent(TransportComponent):
    ' TransportComponent that only uses certain buttons if a shift button is pressed '

It is interesting to note that the EncoderMixerModeSelectorComponent module appears to have been recycled from the AxiomPro script. And, for anyone interested, the rest of the python code can be examined here.

The most interesting module of the lot by far, is APC40.py - which is where most of the action is. This file begins with the imports:

import Live
from _Framework.ControlSurface import ControlSurface
from _Framework.InputControlElement import *
from _Framework.SliderElement import SliderElement
from _Framework.ButtonElement import ButtonElement
from _Framework.EncoderElement import EncoderElement
from _Framework.ButtonMatrixElement import ButtonMatrixElement
from _Framework.MixerComponent import MixerComponent
from _Framework.ClipSlotComponent import ClipSlotComponent
from _Framework.ChannelStripComponent import ChannelStripComponent
from _Framework.SceneComponent import SceneComponent
from _Framework.SessionZoomingComponent import SessionZoomingComponent
from _Framework.ChannelTranslationSelector import ChannelTranslationSelector
from EncoderMixerModeSelectorComponent import EncoderMixerModeSelectorComponent
from RingedEncoderElement import RingedEncoderElement
from DetailViewControllerComponent import DetailViewControllerComponent
from ShiftableDeviceComponent import ShiftableDeviceComponent
from ShiftableTransportComponent import ShiftableTransportComponent
from ShiftableTranslatorComponent import ShiftableTranslatorComponent
from PedaledSessionComponent import PedaledSessionComponent
from SpecialMixerComponent import SpecialMixerComponent

As expected, in addition to the Live import (which provides direct access to the Live API), and the special modules listed previously, all of the other imports are _Framework modules. Again, see Part 1 for more detail.

Next are the constants, which are used in the APC40 sysex exchange (more on this later):

SYSEX_INQUIRY = (240, 126, 0, 6, 1, 247)
MANUFACTURER_ID = 71
PRODUCT_MODEL_ID = 115
APPLICTION_ID = 65
CONFIGURATION_ID = 1

Then, after the name and docstring, we have the obligatory __init__ method. The APC40 __init__ looks like this:

def __init__(self, c_instance):
        ControlSurface.__init__(self, c_instance)
        self.set_suppress_rebuild_requests(True)
        self._suppress_session_highlight = True
        is_momentary = True
        self._shift_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 98)
        self._shift_button.name = 'Shift_Button'
        self._suggested_input_port = 'Akai APC40'
        self._suggested_output_port = 'Akai APC40'
        session = self._setup_session_control()
        mixer = self._setup_mixer_control()
        self._setup_device_and_transport_control()
        self._setup_global_control(mixer)
        session.set_mixer(mixer)
        for component in self.components:
            component.set_enabled(False)
        self.set_suppress_rebuild_requests(False)
        self._device_id = 0
        self._common_channel = 0
        self._dongle_challenge = (Live.Application.get_random_int(0, 2000000), Live.Application.get_random_int(2000001, 4000000))

As we can see, standard Framework-based scripting is used to create a session object, a mixer, device and transport components, and global controls, and then to assign the mixer to the session. There’s nothing very mysterious here – except perhaps _dongle_challenge. And - you may be wondering - where does the infamous “secret handshake” live? Why, in handle_sysex of course.

The Secret Handshake – not so secret anymore

Within days of the APC40 hitting retail shelves, it was discovered that the secret handshake is based on a sysex exchange. But how does it work exactly, and what is it hiding? It’s not hiding anything that can’t be done with basic Framework scripting (as we saw in Part 1), and it works by sending a “dongle challenge” sysex string, then looking for a correct response from the controller. If the response from the controller matches the expected response (i.e. the handshake succeeds), then all of the controls on the controller are enabled and the session highlight (aka “red box”) is turned on. Here’s part of the handle_sysex method, in native python:

def handle_sysex(self, midi_bytes):
        if ((midi_bytes[3] == 6) and (midi_bytes[4] == 2)):
            assert (midi_bytes[5] == MANUFACTURER_ID)
            assert (midi_bytes[6] == PRODUCT_MODEL_ID)
            version_bytes = midi_bytes[9:13]
            self._device_id = midi_bytes[13]
            self._send_midi((240,
                             MANUFACTURER_ID,
                             self._device_id,
                             PRODUCT_MODEL_ID,
                             96,
                             0,
                             4,
                             APPLICTION_ID,
                             self.application().get_major_version(),
                             self.application().get_minor_version(),
                             self.application().get_bugfix_version(),
                             247))
            challenge1 = [0,0,0,0,0,0,0,0]
            challenge2 = [0,0,0,0,0,0,0,0]
            #...
            dongle_message = ((((240,
                                 MANUFACTURER_ID,
                                 self._device_id,
                                 PRODUCT_MODEL_ID,
                                 80,
                                 0,
                                 16) + tuple(challenge1)) + tuple(challenge2)) + (247))
            self._send_midi(dongle_message)
            message = ((('APC40: Got response from controller, version ' + str(((version_bytes[0] << 4) + version_bytes[1]))) + '.') + str(((version_bytes[2] << 4) + version_bytes[3])))
            self.log_message(message)
        elif (midi_bytes[4] == 81):
            assert (midi_bytes[1] == MANUFACTURER_ID)
            assert (midi_bytes[2] == self._device_id)
            assert (midi_bytes[3] == PRODUCT_MODEL_ID)
            assert (midi_bytes[5] == 0)
            assert (midi_bytes[6] == 16)
            response = [long(0),
                        long(0)]
            #...
            expected_response = Live.Application.encrypt_challenge(self._dongle_challenge[0], self._dongle_challenge[1])
            if ((long(expected_response[0]) == response[0]) and (long(expected_response[1]) == response[1])):
                self._suppress_session_highlight = False
                for component in self.components:
                    component.set_enabled(True)
                self._on_selected_track_changed()

The above sysex usage matches that described in the Akai APC40 Communications Protocol document. Well, the standard MMC Device Enquiry and response parts do anyway – the dongle part is not documented. As we can see, some of this exchange involves setting the APC40 into “Ableton Mode” and identifying the host application to the APC40 as Live – complete with major, minor, and bugfix version info.

Although the APC40 handshake is effective at discouraging casual emulation, it is not clear what exactly is being protected here, since any script can make of use the Framework SessionComponent module (or the Live API) to get the infamous “red box” to display. On the other hand, who in their right mind would want to emulate a hard-wired non-programmable MIDI controller with an 8x5 grid of LED buttons, 16 rotary controllers, a cross-fader, and 9 sliders – unless they’re someone who already owns an APC40?! I suspect that the majority of Monome owners probably wouldn’t be interested in using their high-end boutique hardware to emulate a mass-market controller like the APC40, and besides, they’ve already got a wealth of open-source software applications and scripts to play with. So the real mystery is: why the dongle?

In any event, bypassing the handshake is simply a matter of overriding the handle_sysex method. Other than for Mode initialization, sysex is not an important part of the APC40 scripts.

Now, for a change of pace, let’s have a quick look at how we can manipulate the APC40’s LEDs using a remote script. We’ll set up a little light show with some python code, and then call it up from Max.

APC40 Lights & Magic

As described in the APC40 Communications Protocol document, the various LEDs on the APC40 (around 380 of them according to Akai - and hence the need for a separate power supply) can be manipulated via MIDI messages sent to the controller. No sysex voodoo here – simple note on and note off commands is all it takes.

The LEDs are turned on with MIDI note-on messages. The first byte of the message is the note-on part, with the MIDI channel bits used to identify the track number (for those LEDs which are associated with tracks). Individual LEDs are identified using the second byte (the note number), and the third byte is used to set the colour and state of the LED. The APC40 note map is a useful reference here.


The Communications Protocol document lists the following possible colours and states for the clip launch LEDs:

0=off, 1=green, 2=green blink, 3=red, 4=red blink, 5=yellow, 6=yellow blink, 7-127=green

In the _setup_session_control method of the APC40 python script, these are assigned as values:

clip_slot.set_started_value(1)
clip_slot.set_triggered_to_play_value(2)
clip_slot.set_recording_value(3)
clip_slot.set_triggered_to_record_value(4)
clip_slot.set_stopped_value(5)

The values in the python script match the colours and states listed in the Communications Protocol document, although only 5 are assigned above. If we wanted to change the 8x5 grid colour assignments (if we had colour vision deficiency, for example), we’d need to override this part of the script.

Now, based on this information, we’ll set up a random pattern of colours - using the Live API to generate the random values – and then get the colours to scroll down the rows of the matrix. Before we can call our new functions, however, we’ll need a MIDI remote script to put them in. Although we could add the new code straight into the APC40 script (by modifying the source code), instead, let’s create a new script which inherits from the APC40 class. We’ll call our new control surface script APC40plus1.

Here’s the new _init__.py code for our new script:

from APC40plus1 import APC40plus1
def create_instance(c_instance):
    return APC40plus1(c_instance)

And here’s the code for our new APC40plus1 class, complete with lightshow methods:

# http://remotescripts.blogspot.com

from APC40.APC40 import *

class APC40plus1(APC40):
    __module__ = __name__
    __doc__ = ' Main class derived from APC40 '
    def __init__(self, c_instance):
        APC40.__init__(self, c_instance)
        self.show_message("APC40plus1 script loaded")
        self.light_loop()

    def light_loop(self):
        #self.name = 'light_loop'
        for index in range (4, 105, 4): #start lights in 4 ticks; end after 104; step in 4 tick increments
            self.schedule_message(index, self.lights_on) #turn lights on
        for index in range (108, 157, 4): #start turning off after 108 ticks; end after 156; step in 4 tick increments 
            self.schedule_message(index, self.lights_off) #turn lights off
        self.schedule_message(156, self.refresh_state) #refresh the controller to turn clip lights back on
    
    def lights_on(self):
        for col_num in range (8): #load random colour numbers into the buffer row (row 0)
            colour = Live.Application.get_random_int(0, 10) #0=off, 1=green, 2=green blink, 3=red, 4=red blink, 5=yellow, 6=yellow blink, 7-127=green
            if colour % 2 == 0 or colour > 6: #filter out the blinking lights (even numbers) and skew towards having more "off" lights
                colour = 0
            list_of_rows[0][col_num] = colour #load the buffer row
        self.load_leds()
  
    def lights_off(self):
        for col_num in range (8): #step through 8 columns/tracks/channels
            list_of_rows[0][col_num] = 0 #set to zero (lights off)
        self.load_leds()
        
    def load_leds(self):
        for row_num in range (6, 0, -1): #the zero row is a buffer, which gets loaded with a random sequence of colours
            note = 52 + row_num #set MIDI notes to send to APC, starting at 53, which is the first scene_launch button            
            if row_num == 6: #the clip_stop row is out of sequence with the grid
                note = 52 #note number for clip_stop row 
            for col_num in range (8): #8 columns/tracks/channels
                list_of_rows[row_num][col_num] = list_of_rows[row_num-1][col_num] #load each slot from the preceding row
                status_byte = col_num #set channel part of status_byte
                status_byte += 144 #add note_on to channel number
                self._send_midi((status_byte, note, list_of_rows[row_num][col_num])) #for APC LEDs, send (status_byte, note, colour)

list_of_rows = [[0]*8 for index in range(7)] #create an 8 x 7 array of zeros; 8 tracks x (1 buffer row + 5 scene rows + 1 clip stop row)

With these two files saved to a new MIDI remote scripts folder, we can select our new controller script in the MIDI preferences drop-down. This will force Live to compile and run the new script. If the “red box” doesn’t show up, we know that something is wrong, and we can check the Live log file for errors, and trouble-shoot from there. We’ve set up our light show to run on initialization, so it should run immediately, but what we really wanted to demonstrate is that the our functions can be called from Max for Live.

For this, we’ll create a simple Max patch which uses the M4L.api.SelectControlSurface resource patch to get a path to our new function, so that we can bang it with the function call. We send a call light_loop message to the control script, which in turn calls lightshow_on to turns the lights on, and lightshow_off to turn the lights back off - and resets the control surface, so that the LEDs reflect session view state.


And here's the result:


Well, although that was a neat diversion, let’s try to do something more useful here – we’ll re-assign the Metronome button to act as a Device Lock button. We’ll do this using our same APC40plus1 script, together with some method overrides (and without the help of Max for Live this time).

Device Lock – why isn’t this feature standard?

The Framework TransportComponent class contains both of the methods which interest us here:
set_metronome_button and set_device_lock_button. These are inherited by the APC40 class, and can be used to change the button assignments. In order to change one for the other, we need to override the APC40 script method where they are normally assigned. This happens in the _setup_device_and_transport_control section of code. Before we override the method, however, we’ll need to do some basic setup.

First, we instantiate an APC40 ControlSurface object, then we initialize using the APC40’s __init__ method, and finally we override the _setup_device_and_transport_control method, which is where we assign the physical metronome button to act as a device lock button:

# http://remotescripts.blogspot.com

from APC40.APC40 import *

class APC40plus1(APC40):
    __module__ = __name__
    __doc__ = ' Main class derived from APC40 '
    def __init__(self, c_instance):
        APC40.__init__(self, c_instance)
        self.show_message("APC40plus1 script loaded")
        
    def _setup_device_and_transport_control(self): #overriden so that we can to reassign the metronome button to device lock
        is_momentary = True
        device_bank_buttons = []
        device_param_controls = []
        bank_button_labels = ('Clip_Track_Button', 'Device_On_Off_Button', 'Previous_Device_Button', 'Next_Device_Button', 'Detail_View_Button', 'Rec_Quantization_Button', 'Midi_Overdub_Button', 'Device_Lock_Button')
        for index in range(8):
            device_bank_buttons.append(ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 58 + index))
            device_bank_buttons[-1].name = bank_button_labels[index]
            ring_mode_button = ButtonElement(not is_momentary, MIDI_CC_TYPE, 0, 24 + index)
            ringed_encoder = RingedEncoderElement(MIDI_CC_TYPE, 0, 16 + index, Live.MidiMap.MapMode.absolute)
            ringed_encoder.set_ring_mode_button(ring_mode_button)
            ringed_encoder.name = 'Device_Control_' + str(index)
            ring_mode_button.name = ringed_encoder.name + '_Ring_Mode_Button'
            device_param_controls.append(ringed_encoder)
        device = ShiftableDeviceComponent()
        device.name = 'Device_Component'
        device.set_bank_buttons(tuple(device_bank_buttons))
        device.set_shift_button(self._shift_button)
        device.set_parameter_controls(tuple(device_param_controls))
        device.set_on_off_button(device_bank_buttons[1])
        device.set_lock_button(device_bank_buttons[7]) #assign device lock to bank_button 8 (in place of metronome)...
        self.set_device_component(device)
        detail_view_toggler = DetailViewControllerComponent()
        detail_view_toggler.name = 'Detail_View_Control'
        detail_view_toggler.set_shift_button(self._shift_button)
        detail_view_toggler.set_device_clip_toggle_button(device_bank_buttons[0])
        detail_view_toggler.set_detail_toggle_button(device_bank_buttons[4])
        detail_view_toggler.set_device_nav_buttons(device_bank_buttons[2], device_bank_buttons[3])
        transport = ShiftableTransportComponent()
        transport.name = 'Transport'
        play_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 91)
        stop_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 92)
        record_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 93)
        nudge_up_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 100)
        nudge_down_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 101)
        tap_tempo_button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, 0, 99)
        play_button.name = 'Play_Button'
        stop_button.name = 'Stop_Button'
        record_button.name = 'Record_Button'
        nudge_up_button.name = 'Nudge_Up_Button'
        nudge_down_button.name = 'Nudge_Down_Button'
        tap_tempo_button.name = 'Tap_Tempo_Button'
        transport.set_shift_button(self._shift_button)
        transport.set_play_button(play_button)
        transport.set_stop_button(stop_button)
        transport.set_record_button(record_button)
        transport.set_nudge_buttons(nudge_up_button, nudge_down_button)
        transport.set_tap_tempo_button(tap_tempo_button)
        transport.set_quant_toggle_button(device_bank_buttons[5])
        transport.set_overdub_button(device_bank_buttons[6])
        #transport.set_metronome_button(device_bank_buttons[7]) #using this button for lock to device instead...
        bank_button_translator = ShiftableTranslatorComponent()
        bank_button_translator.set_controls_to_translate(tuple(device_bank_buttons))
        bank_button_translator.set_shift_button(self._shift_button)

Now, we save our new APC40plus1 script into its MIDI Remote Scripts directory, fire up Live, and select APC40plus1 from the MIDI Control Surfaces drop-down.

Voilà, the Metronome button is now a Device Lock button (sorry, no video - but trust me - it does work). The python code for this mod (together with the lightshow) can be downloaded here. Now, although we’ve made functional changes without modifying the original APC40 scripts, we’re still dependent on those original scripts, because we inherit methods from them. This means that we’ll need to watch out for MIDI remote script changes in new versions of Live, since they might break our new script. So why don’t we get a head start by checking out compatibility with 8.1.3 RC-1?

Live 8.1.3 – subtle changes

As Live 8.1.3 enters the Release Candidate phase, we can take a sneak peek at the close-to-final MIDI remote scripts included with the beta release. It turns out that not much has changed here. The Framework classes are essentially the same, and support for one or two new control surfaces has been added – most notably the APC20 and Serato controllers. We’ll leave the Serato scripts for a future investigation (they look fascinating by the way), and take a peek under the hood of the APC20 scripts instead.

APC20 – the APC40’s little brother

The APC20 is basically an APC 40 minus the cross-fader, rotary controllers, and right hand buttons. To maintain basic functionality with fewer buttons, some of the buttons on the left hand side are now dual-purpose, as described in the APC20 Quickstart Guide. For example, the APC40’s Stop All Clips button has become the Shift button on the APC20. By holding down this button and selecting one of the Record buttons, the APC20 sliders can be assigned to Volume, Pan, Send A , Send B, Send C, User 1, User 2, or User 3.

One new APC20 feature, which is not shared with the APC40, is Note Mode. The APC20 can be put into Note Mode using the Note Mode button (which was the Master Select button on the APC40). This allows the 8x5 grid to be used for sending MIDI notes - to control a drum rack, for example. So, is there a Note Mode on the APC40?

Sysex is used to set the APC modes, and while APC40 has 3 documented modes, the APC20 has 4. As shown in the APC40 Communications Protocol document, a “Type 0” sysex message is used to set the APC40’s Modes. Byte 8 is the Mode identifier:

0x40 (decimal 64) Generic Mode
0x41 (decimal 65) Ableton Live Mode
0x42 (decimal 66) Alternate Ableton Live Mode

The 8.1.3 MIDI remote scripts show that a new Mode byte value has been added for the APC20:

0x43 (decimal 67) APC20 Note Mode

Unfortunately, this new Mode value (0x43) is meaningless to the APC40 – the hardware does not respond to this value. This is not a remote script limitation, but rather a firmware update would be required in order to for the APC40 to have a Note Mode. So far, none is available. On the other hand, it might well be possible to create a script which emulates Note Mode by translating MIDI Note and Channel data on the fly (there is already a Framework method for channel translation, which could be a good place to start). A future scripting project, perhaps, if Akai decides not to provide firmware updates for the APC40.

Further investigation of the 8.1.3 scripts shows that the structure of the APC40 remote scripts has changed somewhat, in order to accommodate the APC20. Both the APC20 and the APC40 classes are now sub-classes of an APC ‘super’ class. The APC class now handles the handshake, and other methods common to both models. There is also a new APCSessionComponent module, which is used for linking multiple APCs (this module appears to handle “red box” offsets, so that multiple session boxes for multiple APCs will sit side-by-side). Here is the docstring for the class:

class APCSessionComponent(SessionComponent):
    " Special SessionComponent for the APC controllers' combination mode "

Otherwise, little seems to have changed between 8.1.1 and 8.1.3. In fact, our APC40plus1 script runs without error on 8.1.3, even though it was based on 8.1.1 code. Which is good news.

Now, just for fun, we can try to emulate an APC20 using an APC40, by overriding the sysex bytes for Product ID and Mode. The product_model_id bytes for the APC20 would be 123 - for the APC40 it’s 115 (or hex 0x73, as listed in the APC40 Communications Protocol document). So, we need to modify the APC20 script as follows:

def _product_model_id_byte(self):
  return 115 #was originally 123

And, in the APC20 consts.py file, we need to change the Note Mode value from 67 to 65:

MANUFACTURER_ID = 71
ABLETON_MODE = 65
NOTE_MODE = 65  #Was: 67 (= APC20 Note Mode); 65 = APC40 Ableton Mode 1

Well, the emulation does work, but of course, an APC40 is only half alive in APC20 mode – the right hand side is completely inactive – and Note Mode does nothing (yet). On the other hand, the ability to use the shift + record buttons to re-assign sliders is a nice feature. It would of course be possible to build a hybrid script which combines features from both the APC20 and the APC40 scripts, if anyone felt so inclined. Simply a question of finding the time - and we all know that there's never enough of that...

Conclusion

The MIDI remote scripts, and particularly the Framework classes, provide a convenient and relatively straight-forward mechanism for tailoring the operation of control surfaces - with or without Max for Live. The Live 8 scripts do cater to Max for Live, but maintain the same basic functionality they’ve always had. The APC40 scripts provide a good example of this, as do the new APC20 scripts - both of which provide a good platform for customization.

Well, happy scripting - and remember to share your discoveries and custom scripts with the rest of the Live community!

Hanz Petrov
April 13, 2010

Friday, March 12, 2010

Introduction to the Framework Classes

Background

My journey into the world of Ableton MIDI remote scripts began with a search for a better way to set up my FCB1010 as a Live controller.  It didn’t take long before I realized that in order to fully customize my setup, I’d need to explore emulation and learn something about scripting in Python. The results of my explorations are documented here, in hopes that they may be useful to others.

If you’ve found your way here, then you probably already know a thing or two about control surfaces, and you’re probably aware that Live has built-in support for many controllers. (If you’re unfamiliar with basic controller setup procedures, have a look at the MIDI and Key Remote Control section of the Live help file, or check out the Control Surface Reference Lessons in Live’s Help View.)

Live provides its “instant mapping” support for controllers through the use of MIDI Remote Scripts. MIDI remote scripts are written in the Python programming language, and essentially serve to translate MIDI data into instructions for controlling various aspects of the Live application. Each of the controllers which Live supports has a dedicated script folder and its own dedicated scripts. We’ll get into the details later – but first, a bit of history.

Emulation


It has been quite some time since ever-curious and inventive Live users discovered that it is possible to take advantage of Live’s “instant mapping” capabilities by emulating a supported controller (typically using one which is not). The most well-known emulation model is “Mackie emulation”.

Emulation essentially involves telling Live that you have a certain piece of MIDI hardware hooked up, when in fact you do not. The caveat is that your hardware must be capable of supplying Live with the MIDI messages it expects to see coming from the controller which you are emulating. This can be done either by reconfiguring your controller, or by filtering through an intermediate application (such as MIDI-OX). This type of emulation is basically “black box emulation”, since the MIDI remote scripts are not modified (and their inner workings do not need to be understood in order for it to work). Black box emulation is somewhat limiting - the next step was to investigate the scripts themselves.

Script Files

The remote scripts are installed with the Live application, and if your OS is Windows, you should be able to find them here (or in a similar location):
C:\Program Files\Ableton\Live 8.x.x\Resources\MIDI Remote Scripts\

A typical MIDI Remote Scripts directory will contain a series of folders with names similar to the following:
_Axiom
_Framework
_Generic
_MxDCore
_Tools
_UserScript
APC40
Axiom
AxiomPro
Axiom_25_Classic
Axiom_49_61_Classic
etc...

The first few directories, which are named with a leading underscore, will not appear in the Live MIDI preferences control surfaces drop-down list (they are mostly “private” helper scripts).  The other folders contain the python compiled (.PYC) script files for each of the supported controllers. The folder names are used to populate the control surfaces drop-down list in Live (changes in folder names will not be visible in the drop-down until Live is re-started).

Within each folder, there is generally an __init__.pyc file, a .pyc file named after the controller, and one or more additional .pyc files.  As an example, for the Vestax VCM600, the following files are found in the VCM600 directory:
__init__.pyc
VCM600.pyc
ViewTogglerComponent.pyc

PYC files are not readable by humans, however, it was soon discovered that by decompiling the controller script files, the source code could be analyzed - providing a convenient map to the default MIDI mappings, and insight into how MIDI remote scripts actually work.

Sources

Python PYC files are relatively easy to decompile, and the resulting PY files are quite readable - in fact, they are practically identical to the original source files.

Python files can be decompiled in a variety of ways. The Decompyle project at Sourceforge (among others) works well for python files up to version 2.3. There are also online “depyhton” services which work for more recent python files, however, there is no non-commercial service which can handle version 2.5 files.

The version of a PYC file can be determined by examining the first four bytes of the file in a hex editor. The “magic numbers” are as follows:

99 4e 0d 0a python 1.5
Fc c4 0d 0a python 1.6
87 c6 0d 0a python 2.0
2a eb 0d 0a python 2.1
2d ed 0d 0a python 2.2
3b f2 0d 0a python 2.3
6d f2 0d 0a python 2.4
B3 f2 0d 0a python 2.5

The Live 7.x.x scripts have generally be found to be python version 2.2 files, while the 8.x.x scripts are generally python ver. 2.5 (unfortunately).  The Live 7.0.13 scripts in decompyled .PY format have been available here for some time. These files have proven to be extremely useful as a reference for understanding remote scripting.

Early explorations of the decompiled scripts focused on the commonly used consts.py file.  This file is used to define constants in many early-generation scripts – including MIDI note mappings for control surfaces.  No longer any need to pore through MIDI implementation charts, or manually map out MIDI note assignments – it’s all there in the files.  Modifying the consts.py file was an easy way to tailor an emulation, and many went on to create new custom scripts from scratch – some very elaborate.

Many of the links at right point to sites with valuable source code, documentation, and insights into scripting – all well worth exploring. There has also been much investigation into the workings of the LiveAPI, which is equally important (the “dark side” of scripting). Until now, however, there has not been much exploration into a key part of the puzzle - the Framework Classes - recently developed by Ableton.

_Framework Scripts

In the past, scripting seems to have been an “every man for himself” affair. OEMs who wanted native “instant mapping” support presumably had to code their own python scripts, with much redundancy and not much sharing. For simple scripts, this was not a huge problem, however, advanced scripts often consist of many files and hundreds of lines of code. As the control surface market grows and matures, the need for a unified set of helper scripts seems obvious.  It appears that Ableton’s solution to this issue has come in the form of the Framework scripts.

Newer controllers now make extensive (sometimes exclusive) use of the Framework classes.  The list includes the Akai APC40, the Novation Launchpad, the M-Audio Axiom Pro, the Open Labs products, and the Vestax VCM600, with more sure to follow. The Framework scripts are essentially a set of utility classes - a modular library of classes and objects - which handles most of the heavy lifting, and reduces the need to for direct calls to the LiveAPI.

The Framework classes represent the “other half” of the Live Object Model (LOM) – as illustrated by the Max for Live reference documents. The max for Live documents describe the Live API half in some detail, and include indirect reference to the Framework classes (control_surfaces).


The Component and Control (element) names exposed in the Max for Live documents closely mirror the Framework module names. Compare with the script file names in the _Framework directory of the MIDI Remote Scripts folder (sorted here according to type):

Central Base Class:
ControlSurface.

Control Surface Components:
ControlSurfaceComponent
TransportComponent
SessionComponent
ClipSlotComponent
ChannelStripComponent
MixerComponent
DeviceComponent
CompoundComponent
ModeSelectorComponent
SceneComponent
SessionZoomingComponent
TrackEQComponent
TrackFilterComponent
ChannelTranslationSelector

Control Elements:
ControlElement
ButtonElement
ButtonMatrixElement
ButtonSliderElement
EncoderElement
InputControlElement
NotifyingControlElement
PhysicalDisplayElement
SliderElement

Other Classes:
DisplayDataSource
LogicalDisplaySegment

And now it’s time to explore the inner workings of the Framework scripts. We’ll begin by setting up a suitable editing environment.

Editing Scripts

I’ve found that it’s generally best to use a dedicated source code editor for any non-trivial scripting work (if for no other reason than to control the use of whitespace, which Python uses for indentation). In a pinch, however, pretty much any text editor can be used to open and edit a .PY file (but be careful not to mix tabs and spaces if you edit python files in a text editor).  An Integrated Development Environment (IDE) is best, and most of my scripting work has been done with Wing IDE. A free version is available here. Stani’s Python Editor (SPE) is another python IDE which is worth looking at, although  there are many alternatives – suited to many different Operating Systems.

Installing Python itself is an essential part of setting up an Integrated Development Environment. Python is installed as part of the setup routine of some IDE software, or it can be installed separately. Python also comes pre-installed with some Operating Systems (but not Windows).

Strictly speaking, python does not need to be installed for basic remote scripting work, since Live has a built-in python compiler (if Live finds a .PY file in a MIDI remote scripts directory, it will attempt to compile the file on start-up). Nonetheless, the benefits of using an IDE only come with python installed (including joys of auto code-completion).

Typical python script code looks like this (in the Wing IDE editor environment):


Although experience with coding (in any language) is a big advantage, a quick way to get started with python scripting is to just play around. Start with some sample code (copy of a simple script in a new folder, for example), and experiment with cut and paste, and trial and error. Typically, a script with errors will simply not compile, and nothing more will happen. It might be possible to break your Live installation with a bad script, but until you know enough to be dangerous, it’s highly unlikely.

Debugging

Live provides several built-in mechanisms which can simplify debugging. I’ve found that the first key to debugging remote scripts is to make use of the Log file. The log file is a simple text file, which can be found in the Ableton Live Preferences directory:
C:\Documents and Settings\username\Application Data\Ableton\Live 8.x.x\Preferences\Log.txt

Whenever Live encounters an error, it will be written to this file. This includes python compile and execution errors. If something unexpected happens after you’ve edited a script (or if the script doesn’t run at all), have a look through the Log file – the problem can often be pinpointed in this way.

Here’s an example of some bad code:
transport.set_foo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 89))

And here’s what will show up in the Log file:
4812 ms. RemoteScriptError: Traceback (most recent call last):
4813 ms. RemoteScriptError:   File "C:\Program Files\Ableton\Live 8.1\Resources\MIDI Remote Scripts\ProjectX\__init__.py", line 7, in create_instance
4813 ms. RemoteScriptError:     
4814 ms. RemoteScriptError: return ProjectX(c_instance)
4814 ms. RemoteScriptError:   File "C:\Program Files\Ableton\Live 8.1\Resources\MIDI Remote Scripts\ProjectX\ProjectX.py", line 48, in __init__
4815 ms. RemoteScriptError:     
4816 ms. RemoteScriptError: self._setup_transport_control() # Run the transport setup part of the script
4817 ms. RemoteScriptError:   File "C:\Program Files\Ableton\Live 8.1\Resources\MIDI Remote Scripts\ProjectX\ProjectX.py", line 78, in _setup_transport_control
4818 ms. RemoteScriptError:     
4819 ms. RemoteScriptError: transport.set_foo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 89))
4819 ms. RemoteScriptError: AttributeError
4820 ms. RemoteScriptError: : 
4820 ms. RemoteScriptError: 'TransportComponent' object has no attribute 'set_foo_button'
4821 ms. RemoteScriptError: 

The same Log file can also be used for tracing (via the Framework log_message method). Pretty much anything can be traced. Here’s an example:
self.log_message("Captain's log stardate " + str(Live.Application.get_random_int(0, 20000)))

And here’s what will show up in the log file:
255483 ms. RemoteScriptMessage: Captain's log stardate 5399

As I make modifications to a python script, I will frequently recompile to check functionality (i.e. to make sure I haven’t broken anything). I do this by setting the controller to “none” in the MIDI preferences pull-down, then immediately re-selecting my custom controller script by name. This will cause Live to recompile the modified script(s) – no need to re-load the application each time.

Some of the links at right detail other working methods, but I’ve found the above to be sufficient to my needs.

Now, let’s use the Framework classes to build a simple Transport script, as an example.

Example Script

We’ll need to create a new folder in the MIDI Remote Scripts directory, which we can name with anything we want (although be aware that if the name starts with an underscore, it won’t show up in the Preferences drop down). We’ll call ours AAA, so that it appears at the top of the drop-down list.


Next, we’ll need to create two files to put into this folder. The first will be nameed __init__.py file. This file marks our directory as a Python package (for the compiler), and contains only a few lines:
#__init__.py
from Transport import Transport
def create_instance(c_instance):
    return Transport(c_instance)

Next, we’ll create a Transport.py file, which is our main script file (note that the file name won’t appear in the drop-down – only the folder name is important). This file contains a few more lines.
#Transport.py 
#This is a stripped-down script, which uses the Framework classes to assign MIDI notes to play, stop and record.
from _Framework.ControlSurface import ControlSurface # Central base class for scripts based on the new Framework
from _Framework.TransportComponent import TransportComponent # Class encapsulating all functions in Live's transport section
from _Framework.ButtonElement import ButtonElement # Class representing a button a the controller
class Transport(ControlSurface):
    def __init__(self, c_instance):
        ControlSurface.__init__(self, c_instance)
        transport = TransportComponent() #Instantiate a Transport Component
        transport.set_play_button(ButtonElement(True, 0, 0, 61)) #ButtonElement(is_momentary, msg_type, channel, identifier)
        transport.set_stop_button(ButtonElement(True, 0, 0, 63))
        transport.set_record_button(ButtonElement(True, 0, 0, 66))

Now, if we open up Live and select AAA from the MIDI preferences pull-down, Live will compile our .PY files, create corresponding .PYC files in our script folder, and run the scripts.

MIDI notes 60, 61 and 63 on Channel 1 should now be automatically mapped to Play, Stop, and Record respectively.  The python sources for this simple script can be found here. Of course, there are many other simple mappings we could make, following the same basic structure. For example, if we wanted to map Tap Tempo to a key, we’d simply add the following line to our script:
transport.set_tap_tempo_button(ButtonElement(True, 0, 0, 68))

The script above uses one of the most basic Framework modules - the TransportComponent module. In addition to the three methods in the script, other public TransportComponent methods include the following:
   set_stop_button(button)
   set_play_button(button)
   set_seek_buttons(ffwd_button, rwd_button)
   set_nudge_buttons(up_button, down_button)
   set_record_button(button)
   set_tap_tempo_button(button)
   set_loop_button(button)
   set_punch_buttons(in_button, out_button)
   set_metronom_button(button)
   set_overdub_button(sbutton)
   set_tempo_control(control, fine_control)
   set_song_position_control(control)

(Note that set_metronom_button is actually mis-spelled in the 7.x.x Framework, but is corrected to set_metronome_button in 8.x.x. This means that scripts using this method will only run on one version or the other, depending on the spelling used..!)

Most of the Framework files and functions (actually classes and methods) are self-explanatory, and it is sufficient to browse the names of the classes and methods in order to understand what they do. Others are more complicated, and are best understood by referring to sample code. The VCM600, the Launchpad, and the Axiom Pro all make use of the Framework classes, and so does the APC40 (naturally).  These are great scripts to use as references (secret link for attentive readers here). Documentation for the Framework classes, generated from the decompiled sources, can be consulted online here. Again, for the most part, all of the functions work as one would expect them to.

When working with decompiled sources, it is worth noting that many of the scripts are “old school”, pre-dating the development of the Framework classes. While they certainly work, they tend to be much more complicated than newer scripts. The complexity is now handled by the Framework classes, which makes most scripting tasks much simpler.

On the other hand, even scripts based on the Framework can be complicated, especially when additional classes need to be developed to handle special functionality which the Framework does not provide. The APC40 scripts are a good example of complex scripting.

Now, let’s try building a set of scripts that can do some of the fancier things which new generation controllers can do, using the Framework classes (I want a “red box” too!).

ProjectX

We won’t exactly be emulating the APC40 or Launchpad here, since their functionality is so tightly tied to their hardware layouts - although admittedly APC40 emulation could be fun to explore (it probably wouldn’t be of any great use to anyone, however, except possibly an APC40 owner wanting to customize). Instead, we’ll turn a bog standard MIDI keyboard into a two-dimension grid controller, using the Framework classes.

A MIDI keyboard is generally one-dimensional, and we want to do some of the things that a two-dimensional grid controller can do.  To get around this limitation, we’ll use two sets of keys - one for the vertical (scenes) and one for the horizontal (tracks) – a moveable X-Y grid of keys. We’ll call our “controller” ProjectX.

The ProjectX script is made up of two sets of keyboard mappings, which can be used together or independently. Part X is a vertical session component (“red box”), and Part Y is a horizontal session component (“yellow box”).  We’ll keep it relatively simple (it is intended to be used with a standard MIDI keyboard, after all), but we will demonstrate the use of several of the Framework classes and methods along the way - primarily the Session, Mixer and Transport components.

Here is a keyboard map, which shows the note assignments of the mappings we’ll be making.

 The APC40, Launchpad and Monome all have grids of buttons; we’ve split the two grid dimensions into two session boxes here.  The “red box” will be 1 track wide by 7 scenes high, and the “yellow box” will be 7 tracks wide by 1 scene high. The red box represents a set of 7 scenes (or clip slots), and the yellow box represents a set of 7 tracks. Used together, they form a virtual grid of 7 tracks by 7 scenes, each of which is controlled by a separate set of seven “white notes”.  Here’s what they look like in the session view (sorry, no video):


And here is the ProjectX script (red box), that uses Framework magic:
import Live # This allows us (and the Framework methods) to use the Live API on occasion
import time # We will be using time functions for time-stamping our log file outputs

""" All of the Framework files are listed below, but we are only using using some of them in this script (the rest are commented out) """
from _Framework.ButtonElement import ButtonElement # Class representing a button a the controller
#from _Framework.ButtonMatrixElement import ButtonMatrixElement # Class representing a 2-dimensional set of buttons
#from _Framework.ButtonSliderElement import ButtonSliderElement # Class representing a set of buttons used as a slider
from _Framework.ChannelStripComponent import ChannelStripComponent # Class attaching to the mixer of a given track
#from _Framework.ChannelTranslationSelector import ChannelTranslationSelector # Class switches modes by translating the given controls' message channel
from _Framework.ClipSlotComponent import ClipSlotComponent # Class representing a ClipSlot within Live
from _Framework.CompoundComponent import CompoundComponent # Base class for classes encompasing other components to form complex components
from _Framework.ControlElement import ControlElement # Base class for all classes representing control elements on a controller
from _Framework.ControlSurface import ControlSurface # Central base class for scripts based on the new Framework
from _Framework.ControlSurfaceComponent import ControlSurfaceComponent # Base class for all classes encapsulating functions in Live
#from _Framework.DeviceComponent import DeviceComponent # Class representing a device in Live
#from _Framework.DisplayDataSource import DisplayDataSource # Data object that is fed with a specific string and notifies its observers
#from _Framework.EncoderElement import EncoderElement # Class representing a continuous control on the controller
from _Framework.InputControlElement import * # Base class for all classes representing control elements on a controller
#from _Framework.LogicalDisplaySegment import LogicalDisplaySegment # Class representing a specific segment of a display on the controller
from _Framework.MixerComponent import MixerComponent # Class encompassing several channel strips to form a mixer
#from _Framework.ModeSelectorComponent import ModeSelectorComponent # Class for switching between modes, handle several functions with few controls
#from _Framework.NotifyingControlElement import NotifyingControlElement # Class representing control elements that can send values
#from _Framework.PhysicalDisplayElement import PhysicalDisplayElement # Class representing a display on the controller
from _Framework.SceneComponent import SceneComponent # Class representing a scene in Live
from _Framework.SessionComponent import SessionComponent # Class encompassing several scene to cover a defined section of Live's session
from _Framework.SessionZoomingComponent import SessionZoomingComponent # Class using a matrix of buttons to choose blocks of clips in the session
from _Framework.SliderElement import SliderElement # Class representing a slider on the controller
#from _Framework.TrackEQComponent import TrackEQComponent # Class representing a track's EQ, it attaches to the last EQ device in the track
#from _Framework.TrackFilterComponent import TrackFilterComponent # Class representing a track's filter, attaches to the last filter in the track
from _Framework.TransportComponent import TransportComponent # Class encapsulating all functions in Live's transport section

""" Here we define some global variables """
CHANNEL = 0 # Channels are numbered 0 through 15, this script only makes use of one MIDI Channel (Channel 1)
session = None #Global session object - global so that we can manipulate the same session object from within any of our methods 
mixer = None #Global mixer object - global so that we can manipulate the same mixer object from within any of our methods

class ProjectX(ControlSurface):
    __module__ = __name__
    __doc__ = " ProjectX keyboard controller script "
    
    def __init__(self, c_instance):
        """everything except the '_on_selected_track_changed' override and 'disconnect' runs from here"""
        ControlSurface.__init__(self, c_instance)
        self.log_message(time.strftime("%d.%m.%Y %H:%M:%S", time.localtime()) + "--------------= ProjectX log opened =--------------") # Writes message into Live's main log file. This is a ControlSurface method.
        self.set_suppress_rebuild_requests(True) # Turn off rebuild MIDI map until after we're done setting up
        self._setup_transport_control() # Run the transport setup part of the script
        self._setup_mixer_control() # Setup the mixer object
        self._setup_session_control()  # Setup the session object       

        """ Here is some Live API stuff just for fun """
        app = Live.Application.get_application() # get a handle to the App
        maj = app.get_major_version() # get the major version from the App
        min = app.get_minor_version() # get the minor version from the App
        bug = app.get_bugfix_version() # get the bugfix version from the App
        self.show_message(str(maj) + "." + str(min) + "." + str(bug)) #put them together and use the ControlSurface show_message method to output version info to console

        self.set_suppress_rebuild_requests(False) #Turn rebuild back on, now that we're done setting up

    def _setup_transport_control(self):
        is_momentary = True # We'll only be using momentary buttons here
        transport = TransportComponent() #Instantiate a Transport Component
        """set up the buttons"""
        transport.set_play_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 61)) #ButtonElement(is_momentary, msg_type, channel, identifier) Note that the MIDI_NOTE_TYPE constant is defined in the InputControlElement module
        transport.set_stop_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 63))
        transport.set_record_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 66))
        transport.set_overdub_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 68))
        transport.set_nudge_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 75), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 73)) #(up_button, down_button)
        transport.set_tap_tempo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 78))
        transport.set_metronome_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 80)) #For some reason, in Ver 7.x.x this method's name has no trailing "e" , and must be called as "set_metronom_button()"...
        transport.set_loop_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 82))
        transport.set_punch_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 85), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 87)) #(in_button, out_button)
        transport.set_seek_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 90), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 92)) # (ffwd_button, rwd_button)
        """set up the sliders"""
        transport.set_tempo_control(SliderElement(MIDI_CC_TYPE, CHANNEL, 26), SliderElement(MIDI_CC_TYPE, CHANNEL, 25)) #(control, fine_control)
        transport.set_song_position_control(SliderElement(MIDI_CC_TYPE, CHANNEL, 24))

    def _setup_mixer_control(self):
        is_momentary = True
        num_tracks = 7 #A mixer is one-dimensional; here we define the width in tracks - seven columns, which we will map to seven "white" notes
        """Here we set up the global mixer""" #Note that it is possible to have more than one mixer...
        global mixer #We want to instantiate the global mixer as a MixerComponent object (it was a global "None" type up until now...)
        mixer = MixerComponent(num_tracks, 2, with_eqs=True, with_filters=True) #(num_tracks, num_returns, with_eqs, with_filters)
        mixer.set_track_offset(0) #Sets start point for mixer strip (offset from left)
        self.song().view.selected_track = mixer.channel_strip(0)._track #set the selected strip to the first track, so that we don't, for example, try to assign a button to arm the master track, which would cause an assertion error
        """set up the mixer buttons"""        
        mixer.set_select_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 56),ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 54)) #left, right track select      
        mixer.master_strip().set_select_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 94)) #jump to the master track
        mixer.selected_strip().set_mute_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 42)) #sets the mute ("activate") button
        mixer.selected_strip().set_solo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 44)) #sets the solo button
        mixer.selected_strip().set_arm_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 46)) #sets the record arm button
        """set up the mixer sliders"""
        mixer.selected_strip().set_volume_control(SliderElement(MIDI_CC_TYPE, CHANNEL, 14)) #sets the continuous controller for volume
        """note that we have split the mixer functions across two scripts, in order to have two session highlight boxes (one red, one yellow), so there are a few things which we are not doing here..."""
        
    def _setup_session_control(self):
        is_momentary = True
        num_tracks = 1 #single column
        num_scenes = 7 #seven rows, which will be mapped to seven "white" notes
        global session #We want to instantiate the global session as a SessionComponent object (it was a global "None" type up until now...)
        session = SessionComponent(num_tracks, num_scenes) #(num_tracks, num_scenes) A session highlight ("red box") will appear with any two non-zero values
        session.set_offsets(0, 0) #(track_offset, scene_offset) Sets the initial offset of the "red box" from top left
        """set up the session navigation buttons"""
        session.set_select_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 25), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 27)) # (next_button, prev_button) Scene select buttons - up & down - we'll also use a second ControlComponent for this (yellow box)
        session.set_scene_bank_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 51), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 49)) # (up_button, down_button) This is to move the "red box" up or down (increment track up or down, not screen up or down, so they are inversed)
        #session.set_track_bank_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 56), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 54)) # (right_button, left_button) This moves the "red box" selection set left & right. We'll put our track selection in Part B of the script, rather than here...
        session.set_stop_all_clips_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 70))
        session.selected_scene().set_launch_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 30))
        """Here we set up the scene launch assignments for the session"""        
        launch_notes = [60, 62, 64, 65, 67, 69, 71] #this is our set of seven "white" notes, starting at C4
        for index in range(num_scenes): #launch_button assignment must match number of scenes
            session.scene(index).set_launch_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, launch_notes[index])) #step through the scenes (in the session) and assign corresponding note from the launch_notes array
        """Here we set up the track stop launch assignment(s) for the session""" #The following code is set up for a longer array (we only have one track, so it's over-complicated, but good for future adaptation)..
        stop_track_buttons = []
        for index in range(num_tracks):
            stop_track_buttons.append(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 58 + index))   #this would need to be adjusted for a longer array (because we've already used the next note numbers elsewhere)
        session.set_stop_track_clip_buttons(tuple(stop_track_buttons)) #array size needs to match num_tracks        
        """Here we set up the clip launch assignments for the session"""
        clip_launch_notes = [48, 50, 52, 53, 55, 57, 59] #this is a set of seven "white" notes, starting at C3
        for index in range(num_scenes):
            session.scene(index).clip_slot(0).set_launch_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, clip_launch_notes[index])) #step through scenes and assign a note to first slot of each       
        """Here we set up a mixer and channel strip(s) which move with the session"""
        session.set_mixer(mixer) #Bind the mixer to the session so that they move together
        
    def _on_selected_track_changed(self):
        """This is an override, to add special functionality (we want to move the session to the selected track, when it changes)
        Note that it is sometimes necessary to reload Live (not just the script) when making changes to this function"""
        ControlSurface._on_selected_track_changed(self) # This will run component.on_selected_track_changed() for all components
        """here we set the mixer and session to the selected track, when the selected track changes"""
        selected_track = self.song().view.selected_track #this is how to get the currently selected track, using the Live API
        mixer.channel_strip(0).set_track(selected_track)
        all_tracks = ((self.song().tracks + self.song().return_tracks) + (self.song().master_track,)) #this is from the MixerComponent's _next_track_value method
        index = list(all_tracks).index(selected_track) #and so is this
        session.set_offsets(index, session._scene_offset) #(track_offset, scene_offset); we leave scene_offset unchanged, but set track_offset to the selected track. This allows us to jump the red box to the selected track.
               
    def disconnect(self):
        """clean things up on disconnect"""
        self.log_message(time.strftime("%d.%m.%Y %H:%M:%S", time.localtime()) + "--------------= ProjectX log closed =--------------") #Create entry in log file
        ControlSurface.disconnect(self)
        return None

And here is the counterpart ProjectY script (yellow box):
import Live # This allows us (and the Framework methods) to use the Live API on occasion
import time # We will be using time functions for time-stamping our log file outputs

""" We are only using using some of the Framework classes them in this script (the rest are not listed here) """
from _Framework.ButtonElement import ButtonElement # Class representing a button a the controller
from _Framework.ChannelStripComponent import ChannelStripComponent # Class attaching to the mixer of a given track
from _Framework.ClipSlotComponent import ClipSlotComponent # Class representing a ClipSlot within Live
from _Framework.CompoundComponent import CompoundComponent # Base class for classes encompasing other components to form complex components
from _Framework.ControlElement import ControlElement # Base class for all classes representing control elements on a controller
from _Framework.ControlSurface import ControlSurface # Central base class for scripts based on the new Framework
from _Framework.ControlSurfaceComponent import ControlSurfaceComponent # Base class for all classes encapsulating functions in Live
from _Framework.InputControlElement import * # Base class for all classes representing control elements on a controller
from _Framework.MixerComponent import MixerComponent # Class encompassing several channel strips to form a mixer
from _Framework.SceneComponent import SceneComponent # Class representing a scene in Live
from _Framework.SessionComponent import SessionComponent # Class encompassing several scene to cover a defined section of Live's session
from _Framework.SliderElement import SliderElement # Class representing a slider on the controller
from _Framework.TransportComponent import TransportComponent # Class encapsulating all functions in Live's transport section

""" Here we define some global variables """
CHANNEL = 0 # Channels are numbered 0 through 15, this script only makes use of one MIDI Channel (Channel 1)
session = None #Global session object - global so that we can manipulate the same session object from within our methods 
mixer = None #Global mixer object - global so that we can manipulate the same mixer object from within our methods

class ProjectY(ControlSurface):
    __module__ = __name__
    __doc__ = " ProjectY keyboard controller script "
    
    def __init__(self, c_instance):
        ControlSurface.__init__(self, c_instance)
        self.log_message(time.strftime("%d.%m.%Y %H:%M:%S", time.localtime()) + "--------------= ProjectY log opened =--------------") # Writes message into Live's main log file. This is a ControlSurface method.
        self.set_suppress_rebuild_requests(True) # Turn off rebuild MIDI map until after we're done setting up
        self._setup_mixer_control() # Setup the mixer object
        self._setup_session_control()  # Setup the session object
        self.set_suppress_rebuild_requests(False) # Turn rebuild back on, once we're done setting up


    def _setup_mixer_control(self):
        is_momentary = True # We use non-latching buttons (keys) throughout, so we'll set this as a constant
        num_tracks = 7 # Here we define the mixer width in tracks (a mixer has only one dimension)
        global mixer # We want to instantiate the global mixer as a MixerComponent object (it was a global "None" type up until now...)
        mixer = MixerComponent(num_tracks, 0, with_eqs=False, with_filters=False) #(num_tracks, num_returns, with_eqs, with_filters)
        mixer.set_track_offset(0) #Sets start point for mixer strip (offset from left)
        """set up the mixer buttons"""        
        self.song().view.selected_track = mixer.channel_strip(0)._track
        #mixer.selected_strip().set_mute_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 42))
        #mixer.selected_strip().set_solo_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 44))
        #mixer.selected_strip().set_arm_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 46))
        track_select_notes = [36, 38, 40, 41, 43, 45, 47] #more note numbers need to be added if num_scenes is increased
        for index in range(num_tracks):
            mixer.channel_strip(index).set_select_button(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, track_select_notes[index]))
    
    def _setup_session_control(self):
        is_momentary = True
        num_tracks = 7
        num_scenes = 1
        global session #We want to instantiate the global session as a SessionComponent object (it was a global "None" type up until now...)
        session = SessionComponent(num_tracks, num_scenes) #(num_tracks, num_scenes)
        session.set_offsets(0, 0) #(track_offset, scene_offset) Sets the initial offset of the red box from top left
        """set up the session buttons"""
        session.set_track_bank_buttons(ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 39), ButtonElement(is_momentary, MIDI_NOTE_TYPE, CHANNEL, 37)) # (right_button, left_button) This moves the "red box" selection set left & right. We'll use the mixer track selection instead...
        session.set_mixer(mixer) #Bind the mixer to the session so that they move together
        selected_scene = self.song().view.selected_scene #this is from the Live API
        all_scenes = self.song().scenes
        index = list(all_scenes).index(selected_scene)
        session.set_offsets(0, index) #(track_offset, scene_offset)
       
    def _on_selected_scene_changed(self):
        """This is an override, to add special functionality (we want to move the session to the selected scene, when it changes)"""
        """When making changes to this function on the fly, it is sometimes necessary to reload Live (not just the script)..."""
        ControlSurface._on_selected_scene_changed(self) # This will run component.on_selected_scene_changed() for all components
        """Here we set the mixer and session to the selected track, when the selected track changes"""
        selected_scene = self.song().view.selected_scene #this is how we get the currently selected scene, using the Live API
        all_scenes = self.song().scenes #then get all of the scenes
        index = list(all_scenes).index(selected_scene) #then identify where the selected scene sits in relation to the full list
        session.set_offsets(session._track_offset, index) #(track_offset, scene_offset) Set the session's scene offset to match the selected track (but make no change to the track offset)
        
    def disconnect(self):
        """clean things up on disconnect"""
        self.log_message(time.strftime("%d.%m.%Y %H:%M:%S", time.localtime()) + "--------------= ProjectY log closed =--------------") #Create entry in log file
        ControlSurface.disconnect(self)
        return None

Of course, each of these scripts also has a corresponding __init__.py, which looks some thing like this:
from ProjectX import ProjectX

def create_instance(c_instance):
    """ Creates and returns the ProjectX script """
    return ProjectX(c_instance)

To use these scripts, two file folders need to be saved to the MIDI Remote Scripts directory (one for  X, and one for Y), and the two controllers need to be loaded using the MIDI preferences drop-down. The .PY source files are here. Unfortunately, I was not able to find a way to load two session highlight boxes from with one script, which explains the two-folder approach. Either script could be used independently, but then we would lose the X-Y interaction. In any event, the idea was to explore using the Framework classes in a novel way. Maybe not too useful in a real-world application, but hopefully these scripts show some of the Framework classes’ hidden potential.

Conclusion

The Framework classes may evolve with newer versions of Live, and some functions may cease to work as expected. However, since all newer controller scripts seem to be based on the Framework classes, it is likely that change will be kept to a minimum (or at least, hopefully, new methods will not break old ones).  There are risks involved in working with an undocumented function library, but on the other hand, the Framework classes certainly help to make remote scripting easy.

Hopefully this exploration has been helpful to somebody out there.  Go, be creative, have fun - and share your work with others!

Hanz Petrov
March 2010