Tuesday, September 7, 2010

Introduction to the Framework Classes Part 4 - the Final Chapter


Introduction

In Part 1 of this series, I mentioned that my journey into the world of MIDI remote scripts began with a search for a better way of integrating my FCB1010 foot controller into my Live setup. Well, it’s been a fun trip - with lots of interesting side investigations along the way - but now we’ve come full circle and it’s time to finish up what I set out to do in the beginning. In this article, we’ll have a look a couple of new _Framework methods introduced in version 8.1.3, which will allow us to operate scripts in Combination Mode, and we’ll create a generic script which will allow the FCB1010 to work in concert with the APC40 – in fact, we’ll set it up to emulate the APC40. And then we’re pretty much done. Let’s start with combination mode.

Combination Mode – red and yellow and pink and green…

As we saw in Part 1, set_show_highlight, a SessionComponent method, can be used to display the famous “red box”, which represents the portion of a Session View which a control surface is controlling. First seen with the APC40 and Launchpad, the red box is a must have for any clip launcher. New since 8.1.4, however, is a functional Combination Mode – specifically announced for the APC40 and APC20 – which “glues” two or more session highlights together. From the 8.1.4 changelog:

“Combination Mode is now active when multiple Akai APC40/20s are in use. This means that the topmost controller selected in your preferences will control tracks 1-8, the second controller selected will control tracks 9-16, and so on.”

Sounds like fun – let’s see how it’s done.

By looking through the APC sources, we can work our way back to the essential change at work here: the SessionComponent class now has new methods called  _link and _unlink (together with a new attribute _is_linked, and a new list object known as _linked_session_instances). Linking sessions turns out to be no more difficult than calling the SessionComponent’s _link method, as follows:

session = SessionComponent(num_tracks, num_scenes)
session._link()

Now, although multiple session boxes can be linked in this way, we need to manage our session offsets, if we want our sessions to sit beside each other, and not one on top of the other. In other words, we will need to sequentially assign non-zero offsets to our linked sessions, based on the adjacent session’s width. We'll add the required code to the ProjectX script from Part 1, as an illustration.

First, we’ll need a list object to hold the active instances of our ProjectX ControlSurface class, and a static method which will be called at the end of initialisation:

_active_instances = []
    
    def _combine_active_instances():
        track_offset = 0
        for instance in ProjectX._active_instances:
            instance._activate_combination_mode(track_offset)
            track_offset += session.width()

    _combine_active_instances = staticmethod(_combine_active_instances)

We add a  _do_combine call at the end of our init sequence, which in turn calls the _combine_active_instances static method – and _combine_active_instances calls  _activate_combination_mode on each instance.

def _do_combine(self):
        if self not in ProjectX._active_instances:
            ProjectX._active_instances.append(self)
            ProjectX._combine_active_instances()
            
    def _activate_combination_mode(self, track_offset):
        if session._is_linked():
            session._unlink()
        session.set_offsets(track_offset, 0)       
        session._link()

Now that the linking is all set up, we’ll define a _do_uncombine method, to clean things up when we disconnect; we’ll unlink our SessionComponent and remove ourselves from the list of active instances here.

def _do_uncombine(self):
        if ((self in ProjectX._active_instances) and ProjectX._active_instances.remove(self)):
            self._session.unlink()
            ProjectX._combine_active_instances()        
        
    def disconnect(self):
        self._do_uncombine()
        ControlSurface.disconnect(self)  

So here’s what we get when we load several instances of our new ProjectX script via Live's MIDI preferences dialog:



The session highlights are all linked (with an automatic track offset for each instance, which matches the adjacent session highlight width), and when we move any one of them, the others move along together.  By changing the offsets in  _activate_combination_mode, we can get them to stack side-by-side, or one above the other, or if we wanted to, we could indeed stack them one on top of the other. By stacking them one on top of the other, we can control a single session highlight zone with multiple controllers – which is exactly what we want to do with the APC40 and FCB1010 in combination mode.

As of version 8.1.3, the Framework scripts have included support for session linking, but so far, the only official scripts which implement combination mode are the APC scripts (as of 8.1.4). As shown above, however, support for combination mode can be extended to pretty much any Framework-based script. What we want to create then, is a script which will allow the FCB1010 to operate in combination mode together with the APC40. Let’s build one.

FCB1020 - a new script for the FCB1010

The first thing to do when setting out to create a new script is to decide on a functional layout.  If we know what we’re trying to achieve in terms of functionality and operation - before we touch a line of code - we can save ourselves a good deal of time down the road. In this case, we’re looking for an arrangement which will allow the FCB1010 to mirror the operation of the APC40, in so far as possible, and allow for optimized hands-free operation.

Although the options for designing a control script are almost unlimited, generally, the resulting method of operation needs to be intuitive. The FCB1010 has some built-in constraints, but also offers a great deal of flexibility. We have 10 banks of 10 switches and 2 pedals to work with – equivalent to 100 “button” controls and 20 “sliders”. Interestingly, the APC40 has a similar number of buttons and knobs.

If we look at the two controllers side-by-side, a pattern emerges. Each column of the APC40’s grid consists of 5 clip launch buttons (one per scene)  and 5 track control buttons (clip stop, track select, activate, solo, & record). Each of the FCB1010’s 10 banks has 5 switches on the top row, and 5 switches on the bottom row. Based on this parallel, if we assign one FCB1010 bank to each of the APC40’s track columns, the resulting operation will indeed be intuitive, and will closely follow the APC’s layout. We only have 2 pedals per bank, however, so we’ll map them to Track Volume, and Send A – at least for now. Here’s how a typical FCB1010 bank will lay out, together with the APC40 layout for comparison.

Bank 01


APC40

We’ll use this layout for banks 1 through 8 (since the APC40 has 8 track control columns), but because the FCB1010 has 10 banks in total, we have 2 banks left over. Let’s use bank 00 for the Master Track controls, and the scene launch controls, in a similar arrangement to banks 1 through 8. There are no activate, solo or record buttons for the master track, so instead, we’ll map global play, stop and record here:

Bank 00


Now we only have one bank left - bank 09. We’ll use bank 09 for session and track navigation, and for device control. Here’s how it will look:

Bank 09

Although fairly intuitive, the layout described above might not suit everyone’s preferences. Wouldn’t it be nice if the end user could decide on his or her own preferred layout? Live’s User Remote Scripts allow for this kind of thing, so we’ll take a similar approach. Rather than hard-coding the note and controller mappings deep within our new script, we’ll pull all of the assignments out into a separate file, for easy access and editing. It will remain a python .py file (not a .txt file) - in the tradition of consts.py, of Mackie emulation fame - but since .py files are simple text files, they can be edited using any text editor. We’ll call our file MIDI_map.py. Here’s a sample of what it will contain:

# General
PLAY = 7 #Global play
STOP = 8 #Global stop
REC = 9 #Global record
TAPTEMPO = -1 #Tap tempo
NUDGEUP = -1 #Tempo Nudge Up
NUDGEDOWN = -1 #Tempo Nudge Down
UNDO = -1 #Undo
REDO = -1 #Redo
LOOP = -1 #Loop on/off
PUNCHIN = -1 #Punch in
PUNCHOUT = -1 #Punch out
OVERDUB = -1 #Overdub on/off
METRONOME = -1 #Metronome on/off
RECQUANT = -1 #Record quantization on/off
DETAILVIEW = -1 #Detail view switch
CLIPTRACKVIEW = -1 #Clip/Track view switch

# Device Control
DEVICELOCK = 99 #Device Lock (lock "blue hand")
DEVICEONOFF = 94 #Device on/off
DEVICENAVLEFT = 92 #Device nav left
DEVICENAVRIGHT = 93 #Device nav right
DEVICEBANKNAVLEFT = -1 #Device bank nav left
DEVICEBANKNAVRIGHT = -1 #Device bank nav right

# Arrangement View Controls
SEEKFWD = -1 #Seek forward
SEEKRWD = -1 #Seek rewind

# Session Navigation (aka "red box")
SESSIONLEFT = 95 #Session left
SESSIONRIGHT = 96 #Session right
SESSIONUP = -1 #Session up
SESSIONDOWN = -1 #Session down
ZOOMUP = 97 #Session Zoom up
ZOOMDOWN = 98 #Session Zoom down
ZOOMLEFT = -1 #Session Zoom left
ZOOMRIGHT = -1 #Session Zoom right

# Track Navigation
TRACKLEFT = 90 #Track left
TRACKRIGHT = 91 #Track right

# Scene Navigation
SCENEUP = -1 #Scene down
SCENEDN = -1 #Scene up

# Scene Launch
SELSCENELAUNCH = -1 #Selected scene launch

Now we can easily change the layout of any of our banks, by editing this one file. In fact, pretty much anything goes – if we wanted to, we could have different layouts for each of the 10 banks, or leave some banks unassigned, for use with guitar effects, etc. To help with layout planning, an editable PDF template for the FCB1010 is included with the source code on the Support Files page.

Okay, so now it’s time to assemble the code.  Since we’re essentially emulating the APC40 here (yes, I admit that I was wrong in Part 2; APC40 emulation is not so crazy after all), we have a choice between starting with the APC scripts and customizing, or building a new set of scripts which have similar functionality. Since we won’t be supporting shifted operations in our script (for the FCB1010 this would require operation with two feet – difficult to do in an upright position), we will need to make significant changes to the APC scripts. Starting from scratch is definitely an option worth considering. On the other hand, the APC40 script will make for a good roadmap, and while we’re at it, we can include some of the special features of the APC40_22 script from Part 3 here as well.

The structure will be fairly simple. We’ll have an __init__.py module (to identify the directory as a python package), a main module (called FCB1020.py), a MIDI_map.py constants file, and several “special” Framework component override modules. Here’s the file list (compete source code is available on the Support Files page):

__init__.py
FCB1020.py
MIDI_Map.py
SpecialChannelStripComponent.py
SpecialMixerComponent.py
SpecialSessionComponent.py
SpecialTransportComponent.py
SpecialViewControllerComponent.py
SpecialZoomingComponent.py

We won’t go into detail on the Special components, since that topic was covered in Part 3. The main module follows the structure outlined in Part 1, but here's a quick overview. We start with the imports, and then define the combination mode static method (as discussed above):

import Live
from _Framework.ControlSurface import ControlSurface
from _Framework.InputControlElement import *
from _Framework.SliderElement import SliderElement
from _Framework.ButtonElement import ButtonElement
from _Framework.ButtonMatrixElement import ButtonMatrixElement
from _Framework.ChannelStripComponent import ChannelStripComponent 
from _Framework.DeviceComponent import DeviceComponent
from _Framework.ControlSurfaceComponent import ControlSurfaceComponent
from _Framework.SessionZoomingComponent import SessionZoomingComponent
from SpecialMixerComponent import SpecialMixerComponent
from SpecialTransportComponent import SpecialTransportComponent
from SpecialSessionComponent import SpecialSessionComponent
from SpecialZoomingComponent import SpecialZoomingComponent
from SpecialViewControllerComponent import DetailViewControllerComponent
from MIDI_Map import *

class FCB1020(ControlSurface):
    __doc__ = " Script for FCB1010 in APC emulation mode "
    _active_instances = []
    def _combine_active_instances():
        track_offset = 0
        scene_offset = 0
        for instance in FCB1020._active_instances:
            instance._activate_combination_mode(track_offset, scene_offset)
            track_offset += instance._session.width()
    _combine_active_instances = staticmethod(_combine_active_instances)

Next we have our init method, where we instantiate our ControlSurface component and call the various setup methods. We setup the session, then setup the mixer, then assign the mixer to the session, to keep them in sync. The disconnect method follows, where we provide some cleanup for when the control surface is disconnected:

def __init__(self, c_instance):
        ControlSurface.__init__(self, c_instance)
        self.set_suppress_rebuild_requests(True)
        self._note_map = []
        self._ctrl_map = []
        self._load_MIDI_map()
        self._session = None
        self._session_zoom = None
        self._mixer = None
        self._setup_session_control()
        self._setup_mixer_control()
        self._session.set_mixer(self._mixer)
        self._setup_device_and_transport_control()
        self.set_suppress_rebuild_requests(False)
        self._pads = []
        self._load_pad_translations()
        self._do_combine()

    def disconnect(self):
        self._note_map = None
        self._ctrl_map = None
        self._pads = None
        self._do_uncombine()
        self._shift_button = None
        self._session = None
        self._session_zoom = None
        self._mixer = None
        ControlSurface.disconnect(self)

The balance of the combination mode methods are next:

def _do_combine(self):
        if self not in FCB1020._active_instances:
            FCB1020._active_instances.append(self)
            FCB1020._combine_active_instances()

    def _do_uncombine(self):
        if ((self in FCB1020._active_instances) and FCB1020._active_instances.remove(self)):
            self._session.unlink()
            FCB1020._combine_active_instances()

    def _activate_combination_mode(self, track_offset, scene_offset):
        if TRACK_OFFSET != -1:
            track_offset = TRACK_OFFSET
        if SCENE_OFFSET != -1:
            scene_offset = SCENE_OFFSET
        self._session.link_with_track_offset(track_offset, scene_offset)    

The session setup is based on Framework SessionComponent methods, with SessionZoomingComponent navigation thrown in for good measure:

def _setup_session_control(self):
        is_momentary = True
        self._session = SpecialSessionComponent(8, 5)
        self._session.name = 'Session_Control'
        self._session.set_track_bank_buttons(self._note_map[SESSIONRIGHT], self._note_map[SESSIONLEFT])
        self._session.set_scene_bank_buttons(self._note_map[SESSIONDOWN], self._note_map[SESSIONUP])
        self._session.set_select_buttons(self._note_map[SCENEDN], self._note_map[SCENEUP])
        self._scene_launch_buttons = [self._note_map[SCENELAUNCH[index]] for index in range(5) ]
        self._track_stop_buttons = [self._note_map[TRACKSTOP[index]] for index in range(8) ]
        self._session.set_stop_all_clips_button(self._note_map[STOPALLCLIPS])
        self._session.set_stop_track_clip_buttons(tuple(self._track_stop_buttons))
        self._session.set_stop_track_clip_value(2)
        self._session.selected_scene().name = 'Selected_Scene'
        self._session.selected_scene().set_launch_button(self._note_map[SELSCENELAUNCH])
        self._session.set_slot_launch_button(self._note_map[SELCLIPLAUNCH])        
        for scene_index in range(5):
            scene = self._session.scene(scene_index)
            scene.name = 'Scene_' + str(scene_index)
            button_row = []
            scene.set_launch_button(self._scene_launch_buttons[scene_index])
            scene.set_triggered_value(2)
            for track_index in range(8):
                button = self._note_map[CLIPNOTEMAP[scene_index][track_index]]
                button_row.append(button)
                clip_slot = scene.clip_slot(track_index)
                clip_slot.name = str(track_index) + '_Clip_Slot_' + str(scene_index)
                clip_slot.set_launch_button(button)
        self._session_zoom = SpecialZoomingComponent(self._session)   
        self._session_zoom.name = 'Session_Overview'
        self._session_zoom.set_nav_buttons(self._note_map[ZOOMUP], self._note_map[ZOOMDOWN], self._note_map[ZOOMLEFT], self._note_map[ZOOMRIGHT]) 

Mixer, device, and transport setup methods are similar.

def _setup_mixer_control(self):
        is_momentary = True
        self._mixer = SpecialMixerComponent(8)
        self._mixer.name = 'Mixer'
        self._mixer.master_strip().name = 'Master_Channel_Strip'
        self._mixer.master_strip().set_select_button(self._note_map[MASTERSEL])
        self._mixer.selected_strip().name = 'Selected_Channel_Strip'  
        self._mixer.set_select_buttons(self._note_map[TRACKRIGHT], self._note_map[TRACKLEFT])
        self._mixer.set_crossfader_control(self._ctrl_map[CROSSFADER])
        self._mixer.set_prehear_volume_control(self._ctrl_map[CUELEVEL])
        self._mixer.master_strip().set_volume_control(self._ctrl_map[MASTERVOLUME])        
        for track in range(8):
            strip = self._mixer.channel_strip(track)
            strip.name = 'Channel_Strip_' + str(track)
            strip.set_arm_button(self._note_map[TRACKREC[track]])
            strip.set_solo_button(self._note_map[TRACKSOLO[track]])
            strip.set_mute_button(self._note_map[TRACKMUTE[track]])
            strip.set_select_button(self._note_map[TRACKSEL[track]])
            strip.set_volume_control(self._ctrl_map[TRACKVOL[track]])
            strip.set_pan_control(self._ctrl_map[TRACKPAN[track]])
            strip.set_send_controls((self._ctrl_map[TRACKSENDA[track]], self._ctrl_map[TRACKSENDB[track]], self._ctrl_map[TRACKSENDC[track]]))
            strip.set_invert_mute_feedback(True)

def _setup_device_and_transport_control(self):
        is_momentary = True
        self._device = DeviceComponent()
        self._device.name = 'Device_Component'
        device_bank_buttons = []
        device_param_controls = []
        for index in range(8):
            device_param_controls.append(self._ctrl_map[PARAMCONTROL[index]])
            device_bank_buttons.append(self._note_map[DEVICEBANK[index]])        
        if None not in device_bank_buttons:
            self._device.set_bank_buttons(tuple(device_bank_buttons))
        self._device.set_parameter_controls(tuple(device_param_controls))
        self._device.set_on_off_button(self._note_map[DEVICEONOFF])
        self._device.set_bank_nav_buttons(self._note_map[DEVICEBANKNAVLEFT], self._note_map[DEVICEBANKNAVRIGHT])
        self._device.set_lock_button(self._note_map[DEVICELOCK])
        self.set_device_component(self._device)     

        detail_view_toggler = DetailViewControllerComponent()
        detail_view_toggler.name = 'Detail_View_Control'
        detail_view_toggler.set_device_clip_toggle_button(self._note_map[CLIPTRACKVIEW])
        detail_view_toggler.set_detail_toggle_button(self._note_map[DETAILVIEW])
        detail_view_toggler.set_device_nav_buttons(self._note_map[DEVICENAVLEFT], self._note_map[DEVICENAVRIGHT] )

        transport = SpecialTransportComponent()
        transport.name = 'Transport'
        transport.set_play_button(self._note_map[PLAY])
        transport.set_stop_button(self._note_map[STOP])
        transport.set_record_button(self._note_map[REC])
        transport.set_nudge_buttons(self._note_map[NUDGEUP], self._note_map[NUDGEDOWN])
        transport.set_undo_button(self._note_map[UNDO])
        transport.set_redo_button(self._note_map[REDO])
        transport.set_tap_tempo_button(self._note_map[TAPTEMPO])
        transport.set_quant_toggle_button(self._note_map[RECQUANT])
        transport.set_overdub_button(self._note_map[OVERDUB])
        transport.set_metronome_button(self._note_map[METRONOME])
        transport.set_tempo_control(self._ctrl_map[TEMPOCONTROL])
        transport.set_loop_button(self._note_map[LOOP])
        transport.set_seek_buttons(self._note_map[SEEKFWD], self._note_map[SEEKRWD])        
        transport.set_punch_buttons(self._note_map[PUNCHIN], self._note_map[PUNCHOUT])  

We’ve also included a DetailViewComponent above, which communicates session view changes via the Live API. Next is _on_selected_track_changed, a ControlSurface class method override, which keeps the selected track’s device in focus. And for drum rack note mapping, we’ve included a _load_pad_translations method, which adds x and y offsets to the Drum Rack note and channel assignments, which are set in the MIDI_map.py file. This allows us to pass the translations array as an argument to the ControlSurface set_pad_translations method in the expected format.

def _on_selected_track_changed(self):
        ControlSurface._on_selected_track_changed(self)
        track = self.song().view.selected_track
        device_to_select = track.view.selected_device
        if device_to_select == None and len(track.devices) > 0:
            device_to_select = track.devices[0]
        if device_to_select != None:
            self.song().view.select_device(device_to_select)
        self._device_component.set_device(device_to_select)

    def _load_pad_translations(self):
        if -1 not in DRUM_PADS:
            pad = []
            for row in range(4):
                for col in range(4):
                    pad = (col, row, DRUM_PADS[row*4 + col], PADCHANNEL,)
                    self._pads.append(pad)
            self.set_pad_translations(tuple(self._pads))

Finally, we have _load_MIDI_map. Here, we create a list of ButtonElements and a list of SliderElements. When we make mapping assignments in our MIDI_map.py file, we are actually indexing objects from these lists. By instantiating the ButtonElements and SliderElements as independent objects, we limit the risk of duplicate MIDI assignments, which would prevent our script from loading. Any particular MIDI note/channel message from a control surface can only be assigned to a single InputControlElement (such as a button or slider), however, an InputControlElement can be used more than once, with different components. This setup also allows us to append None to the end of each list, so that null assignments can be specified in the MIDI_map file, by using -1 in place of a note number (in python, [-1] corresponds to the last element of a list).

def _load_MIDI_map(self):
        is_momentary = True
        for note in range(128):
            button = ButtonElement(is_momentary, MIDI_NOTE_TYPE, NOTECHANNEL, note) 
            button.name = 'Note_' + str(note)
            self._note_map.append(button) 
        self._note_map.append(None) #add None to the end of the list, selectable with [-1]
        for ctrl in range(128):
            control = SliderElement(MIDI_CC_TYPE, CTRLCHANNEL, ctrl)
            control.name = 'Ctrl_' + str(ctrl)
            self._ctrl_map.append(control) 
        self._ctrl_map.append(None)

Now, speaking of MIDI assignments, since all of our mappings are editable, and grouped in a separate file, couldn’t we use our script with just about any control surface, and not only the FCB1010? Yes, indeed we could.

Generic APC Emulation

Our new FCB10120 script can be used as a generic APC emulator, since it merely maps MIDI Note and CC input to specific _Framework component functions, mimicking the APC script setup. In fact, none of this is very different from the User script mechanism provided by Ableton - although our script has a few extra features that the User script does not support, including Session Highlighting (aka “red box”), and the ability to work in combination mode, with an APC40, or with another instance of itself, or with any other controller which supports combination mode.

Some setup is required, however, to accommodate alternate controller configurations. These configurations could be alternate configurations for the same control surface, or alternate configurations for different control surfaces.

Probably the simplest way of setting up an alternate configuration, is to create one or more copies of the FCB1020 script folder, modify the assignments in the MIDI_map.py file as required, and then re-name the directory to suit. The new folder will be selectable by name from Live’s control surface drop-down list, the next time Live is started. This way, one could have, say, FCB1020_device_mode and FCB1020_transport_mode as separate configurations, listed one above the other in the control surface drop-down. Note however, that one should avoid leading underscores in folder name, unless a folder is intended to be hidden.

Another way to accommodate alternate setups would be to reprogram the control surface itself – where this is possible - to match the note and CC mappings found in the MIDI_map.py file. Depending on the control surface, this could be done manually, with stand-alone software, or by using Live’s “dump” button from the preferences dialog (in fact, a sysex file for the FCB1010 is included with the support files package which accompanies this article, for this purpose).

Of course, our script can also be used as a generic APC emulator for multiple controllers at the same time. This can be done a number of ways, including:
1) Daisy-chain several control surfaces using MIDI Thru ports;
2) Load the script multiple times, using multiple slots;
3) Create multiple copies of the script folder, rename to suit, and load into different slots.

For multiple control surfaces which use different MIDI channels, separate instances of the script would need to be loaded, from separate folders, with the channel assignments in the MIDI_maps.py modified to suit in each folder.

And getting back to our original design setup, we can see that the FCB1010 and the APC40 now work well together in Combination Mode, and the FCB1010 is able to control of most of the APC’s functions – within one session highlight area, and without loss of focus. We have included a good deal of flexibility in our script too, so we can easily modify the various bank layouts to suit our needs, as they develop and change.

Conclusion


In this new era of multi-touch controllers, it’s nice to know that a sturdy old workhorse like the FCB1010 still has a place in our arsenal of control surfaces - and that it works well in combination with the soon-to-be-a-classic and not-yet-obsolete APC40. As for MIDI remote scripts, they’re still at the heart of all control surface communications with the Live API - and the _Framework classes have been holding their own for quite a while now, with interesting new methods being added from time to time. Hopefully, this series of articles has been useful, and will encourage others to share their findings with the Live community. Happy scripting.

Hanz Petrov
September 7, 2010

hanz.petrov
at gmail.com

Thursday, May 13, 2010

Introduction to the Framework Classes Part 3

Introduction

As we’ve demonstrated previously, the _Framework classes provide a very useful framework for coding MIDI control surface scripts in Python. We’ve looked at basic script setup, worked with simple Control Surface components and objects, and over-ridden high-level setup methods to achieve certain customization goals. Here in this post, we’ll have a look at the relationship between the _Framework classes and the “special” subclass modules which are used with many of the Framework-based MIDI remote scripts for newer controllers.

As a working example, we’ll be adding APC20 functionality to the APC40 by modifying the APC40’s MIDI remote scripts. Our modifications will include Note Mode for the matrix, and User Mode for the sliders. We’ll also have another look at Device Lock, create mappings for Undo and Redo, and re-map the APC40’s endless encoder to control Tempo. For those who wish to follow along, the complete Python source files which accompany this article can be downloaded here.

Sysex and Subclasses

As we’ve seen previously, the APC20 can be put into Note Mode by sending an appropriate Sysex string to the controller. In this mode, the 8x5 matrix can be used to send MIDI notes, trigger samples, control drum racks, etc. - similar to the Launchpad’s User modes. For the APC40, however, a firmware upgrade would be required in order enable Note Mode via Sysex. Fortunately, Sysex is not the only way. The Launchpad’s User Modes do not rely on Sysex, and we can use the same approach to add a Note Mode to the APC40. Before we do, however, let’s collect the scripts we need in order to combine the useful new features of the APC20 with those of the APC40.

In Live 8.1.3, the APC20 and APC40 scripts are both subclasses of the APC “super-class” script, although the APC module actually resides in the APC40 folder, while the APC20 scripts are found in a separate folder. Here, we’ll copy all of the required scripts over into a new folder, which we’ll call APC40_20. Our modified controller script will then be selectable in Live’s MIDI preferences drop-down list as APC40_20.

Since any “shifted” controls generally require a new subclass (as they inherit from and override Framework base class methods), and since many of the controls on both the APC40 and on the APC20 work in a shifted mode, we’ll need to include a fair number of “special” subclass modules. Here is a list of the modules we’ll be needing (in addition to the Framework modules, which are not listed):

__init__.py
APC.py (APC40 and APC20)
APC40plus20.py (based on APC40 script)
APCSessionComponent.py (APC20)
ConfigurableButtonElement.py (Launchpad)
DetailViewControllerComponent.py (APC40)
EncoderMixerModeSelectorComponent.py (APC40)
PedaledSessionComponent.py (APC40)
RingedEncoderElement.py (APC40)
ShiftableDeviceComponent.py (APC40)
ShiftableSelectorComponent.py (APC20)
ShiftableTranslatorComponent.py (APC40)
ShiftableTransportComponent.py (APC40)
ShiftableZoomingComponent.py (APC20)
SliderModesComponent.py (APC20)
SpecialChannelStripComponent.py (APC40)
SpecialMixerComponent.py (APC40)
SpecialTransportComponent.py (APC40)

Note that we’ve included one non-APC module here, ConfigurableButtonElement, which is from the Launchpad scripts. One advantage of using the Framework classes for scripting is that special subclass modules are generally re-usable and interchangeable, especially for similar control surfaces.

Now, with all of the subclass scripts gathered together in one place (for ease of editing and transportability), we’ll merge the APC20 and APC40 scripts. Both of these scripts are Framework-based, so there will be some import duplicates, which we can eliminate. There are also some APC20 special subclass modules which we will be using instead of the APC40’s plain vanilla Framework imports. Here is the merged import list:

import Live
from APC import APC
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 #use ShiftableZoomingComponent from APC20 scripts instead
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

# Additional imports from APC20.py:
from ShiftableZoomingComponent import ShiftableZoomingComponent
from ShiftableSelectorComponent import ShiftableSelectorComponent
from SliderModesComponent import SliderModesComponent

# Import added from Launchpad scripts - needed for Note Mode:
from ConfigurableButtonElement import ConfigurableButtonElement 

Next we compare the APC20 and APC40 scripts for differences, retaining the most useful parts of each. Since most of the setting-up in the APC20 script setup is identical to that of APC40, and since the APC40 has more controls than the APC20, we’ll base our modified script on APC40 script, adding relevant code from the APC20 script as needed. The basic set-up of our script includes the following components:

SessionComponent (includes session matrix, “red box” navigation controls, scene and clip launch buttons, and stop buttons)
SessionZoomingComponent (includes “zoomed out” controls: matrix, selection, navigation, etc., for “banks of scenes”)
MixerComponent (includes solo, mute, arm, and volume controls for tracks)
TransportComponent (includes play, stop, record, tempo, metronome, quantize, etc., for song)
DeviceComponent (includes device control, device on-off, device navigation, etc.)

These are basic Framework components, however, with a complex controller like the APC40, most are actually instantiated as special subclass objects. The special subclass modules are typically used for over-riding existing Framework class methods, and for adding new methods - and often include direct calls to the LiveAPI. However, since they generally provide easily understood features, their usage is straight-forward and we can include most of them as-is, without modification, and without further investigation. We do need to sort out the conflicts between the APC40 and APC20 scripts, however - and add new code where we want to do new things or do things in different ways. It turns out that the only special modules which we’ll need to modify here are the following:

SpecialMixerComponent.py – we will map Cue Level to Tempo here
ShiftableDeviceComponent.py – we will check the Device Lock state here
ShiftableSelectorComponent.py – we will implement Note Mode here
ShiftableTransportComponent.py – we will add Undo and Redo, and handle the true endless encoder here
ShiftableZoomingComponent.py – we will modify the scope of Note Mode here (keep scene launch active)

Mapping Cue Level to Tempo provides a good example of subclass modification. It turns out that there is only one true endless encoder on the APC40/20, which is the Cue Level control. This is the control which we want to use as a Tempo control, due to its unique placement on the board. Unfortunately, however, the simple set_tempo_control Framework TransportComponent method, which we would normally use, throws an assertion error when mapped to a true endless encoder:

def set_tempo_control(self, control, fine_control = None):
        assert ((control == None) or (isinstance(control, EncoderElement) and (control.message_map_mode() is Live.MidiMap.MapMode.absolute)))
        assert ((fine_control == None) or (isinstance(fine_control, EncoderElement) and (fine_control.message_map_mode() is Live.MidiMap.MapMode.absolute)))

Our true endless encoder is an EncoderElement with a map_mode value of Live.MidiMap.MapMode.relative_two_compliment, not Live.MidiMap.MapMode.absolute, so, we’ll need to dig deeper. Luckily, there’s an “old-fashioned” script which shows us the way - we can adapt code from the Mackie Control script to suit our purpose here (the Mackie jog wheel is also a true endless encoder). In the ShiftableTransportComponent module, we add a method for assigning the control, which now will throw an assertion error if the mapped control is not an endless encoder:

def set_tempo_encoder(self, control):
        assert ((control == None) or (isinstance(control, EncoderElement) and (control.message_map_mode() is Live.MidiMap.MapMode.relative_two_compliment)))
        if (self._tempo_encoder_control != None):
            self._tempo_encoder_control.remove_value_listener(self._tempo_encoder_value)
        self._tempo_encoder_control = control
        if (self._tempo_encoder_control != None):
            self._tempo_encoder_control.add_value_listener(self._tempo_encoder_value)
        self.update()

And we need to add the callback (i.e., the “listener value”). This is the method which is called whenever the controller (encoder) is adjusted:

def _tempo_encoder_value(self, value):
        if not self._shift_pressed:
            assert (self._tempo_encoder_control != None)
            assert (value in range(128))
            backwards = (value >= 64)
            step = 0.1 #step = 1.0 #reduce this for finer control; 1.0 is 1 bpm
            if backwards:
                amount = (value - 128)
            else:
                amount = value
            tempo = max(20, min(999, (self.song().tempo + (amount * step))))
            self.song().tempo = tempo

Note that we’ve changed the step increment to a reasonably smooth value of 0.1 bpm, since we’re only using one encoder for tempo control here (the Framework basic transport.set_tempo_control method, which we’re not using, is actually set up to accept two controller assignments - one for coarse tuning and one for fine tempo tuning). If we wanted to, we could also adjust the Tempo range min and max values here as well.

We can see that the _tempo_encoder_value method ends with a call to the LiveAPI; self.song().tempo = tempo. The LiveAPI is well documented, so these types of calls are easy to look up, when they're not easliy understood at first glance. We can also refer to the many MIDI remote scripts, and the _Framework scripts themselves, for examples of how to make the calls and what arguments to use.

In the _tempo_encoder_value method above, we check to see if shift is pressed before doing anything else, because we want to revert to the original Cue Volume mapping when in shifted mode. We’ll also need to add some code to the SpecialMixerComponent module to do this. The _shift_value method is called when the shift button is pressed, which in turn calls the update() method; this is where we’ll connect our “prehear” (Cue Volume) control - right before the Crossfader control connection is made:

def _shift_value(self, value): #added
        assert (self._shift_button != None)
        assert (value in range(128))
        self._shift_pressed = (value != 0)
        self.update()

def update(self): #added override
        if self._allow_updates:
            master_track = self.song().master_track
            if self.is_enabled():
                if (self._prehear_volume_control != None):
                    if self._shift_pressed: #added 
                        self._prehear_volume_control.connect_to(master_track.mixer_device.cue_volume)
                    else:
                        self._prehear_volume_control.release_parameter() #added        
                if (self._crossfader_control != None):
                    self._crossfader_control.connect_to(master_track.mixer_device.crossfader)
            else:
                if (self._prehear_volume_control != None):
                    self._prehear_volume_control.release_parameter()
                if (self._crossfader_control != None):
                    self._crossfader_control.release_parameter()
                if (self._bank_up_button != None):
                    self._bank_up_button.turn_off()
                if (self._bank_down_button != None):
                    self._bank_down_button.turn_off()
                if (self._next_track_button != None):
                    self._next_track_button.turn_off()
                if (self._prev_track_button != None):
                    self._prev_track_button.turn_off()
            self._rebuild_callback()
        else:
            self._update_requests += 1

So far, so good. With some higher-level remote script adjustments and some lower-level subclass code modifications, we’ve successfully mapped Cue Level to Tempo. Now, on to Note Mode.

Note Mode

In order to enable Note Mode, first we need to disable the clip slots, so that we can re-assign the matrix buttons to the MIDI notes of our chosing. We don’t want to totally disconnect the ButtonElements, we only want to change the mappings. The original APC20 script actually disables the SessionComponent altogether, however, this also puts the Scene Launch and Track Stop buttons out of action. To take out only the matrix, we need to disable the clip slots, and nothing else. We’ll do this in the set_ignore_buttons method of the ShiftableZoomingComponent class:

def set_ignore_buttons(self, ignore):
        assert isinstance(ignore, type(False))
        if (self._ignore_buttons != ignore): #if ignore state changes..
            self._ignore_buttons = ignore #set new state
            if (not self._is_zoomed_out): #if in session/clip view..
                if ignore: #disable clip slots on ignore
                    for scene_index in range(5):
                        scene = self._session.scene(scene_index)
                        for track_index in range(8):
                            clip_slot = scene.clip_slot(track_index)
                            clip_slot.set_enabled(False)                          
                else: #re-enable clip slots on ignore
                    for scene_index in range(5):
                        scene = self._session.scene(scene_index)
                        for track_index in range(8):
                            clip_slot = scene.clip_slot(track_index)
                            clip_slot.set_enabled(True)                          
                #self._session.set_enabled((not ignore)) 

            self.update()


Now that the clip slots are disabled, we can re-map the matrix buttons to MIDI notes. We’ll do this in the _on_note_mode_changed method of the ShiftableSelectorComponent class. We’ll use a note layout which is similar to the APC20’s Note Mode layout, and to the Launchpad’s User 1 mode layout – both of which are based on split rows. Each half row will ascend in sets of 4 notes, which works well with the typical Live Rack (also 4 notes wide). Here's a map of our new Note Mode layout (together with the other mods in our script):


In order to avoid conflict with the APC40 assignments, we’ll send all of our Note Mode notes out on channel 10 (the APC40 uses channels 1 through 8 - and possibly 9, according to the original note map). To map the notes and to change the channels, we’ll use the Framework set_channel and set_identifier methods. We’ll also use the Framework button.send_value method to create different colour patterns for the left and right sides of the grid – so that our note mapping is visually obvious. Here’s the code:

def _on_note_mode_changed(self):
        if not self._master_button != None:
            raise AssertionError
        if self.is_enabled() and self._invert_assignment == self._toggle_pressed:
            if self._note_mode_active:
                self._master_button.turn_on()
                for scene_index in range(5):
                    #TODO: re-map scene_launch buttons to note velocity...
                    scene = self._session.scene(scene_index)
                    for track_index in range(8):
                        clip_slot = scene.clip_slot(track_index)
                        button = self._matrix.get_button(track_index, scene_index)
                        clip_slot.set_launch_button(None)                        
                        button.set_enabled(False)
                        button.set_channel(9) #remap all Note Mode notes to channel 10
                        if track_index < 4:
                            button.set_identifier(52 - (4 * scene_index) + track_index) #top row of left group (first 4 columns) notes 52 to 55
                            if (track_index % 2 == 0 and scene_index % 2 != 0) or (track_index % 2 != 0 and scene_index % 2 == 0):
                                button.send_value(1) #0=off, 1=green, 2=green blink, 3=red, 4=red blink, 5=yellow, 6=yellow blink, 7-127=green
                            else:
                                button.send_value(5)
                        else:
                            button.set_identifier(72 - (4 * scene_index) + (track_index -4)) #top row of right group (next 4 columns) notes 72 to 75
                            if (track_index % 2 == 0 and scene_index % 2 != 0) or (track_index % 2 != 0 and scene_index % 2 == 0):
                                button.send_value(1) #0=off, 1=green, 2=green blink, 3=red, 4=red blink, 5=yellow, 6=yellow blink, 7-127=green
                            else:
                                button.send_value(3)
                self._rebuild_callback()
            else:
                self._master_button.turn_off()
        return None

Of course, we need to turn the lights off and re-enable the clip slots when Note Mode is switched back off. We’ll do that in the _master_value method (which is also where the APC20 Sysex Note Mode string normally gets queued-up):

def _master_value(self, value): #this is the master_button value_listener, i.e. called when the master_button is pressed
        if not self._master_button != None:
            raise AssertionError
        if not value in range(128):
            raise AssertionError
        if self.is_enabled() and self._invert_assignment == self._toggle_pressed:
            if not self._master_button.is_momentary() or value > 0: #if the master button is pressed:
                #for button in self._select_buttons: #turn off track select buttons (only needed for APC20)
                    #button.turn_off()
                self._matrix.reset() #turn off the clip launch grid LEDs
                #mode_byte = NOTE_MODE #= 67 for APC20 Note Mode, send as part of sysex string to enable Note Mode
                if self._note_mode_active: #if note mode is already on, turn it off:
                    #mode_byte = ABLETON_MODE #= 65 for APC40 Ableton Mode 1
                    for scene_index in range(5):
                        scene = self._session.scene(scene_index)
                        for track_index in range(8):
                            clip_slot = scene.clip_slot(track_index)
                            button = self._matrix.get_button(track_index, scene_index)
                            clip_slot.set_launch_button(button)
                            button.set_enabled(True)
                            button.turn_off()
                    self._rebuild_callback()
                #self._mode_callback(mode_byte) #send sysex to set Mode (NOTE_MODE or ABLETON_MODE)
                self._note_mode_active = not self._note_mode_active
                self._zooming.set_ignore_buttons(self._note_mode_active) #turn off matrix, scene launch, and clip stop buttons when in Note Mode
                #self._transport.update() #only needed for APC20
                self._on_note_mode_changed()
        return None

So now we have a working Note Mode on the APC40 (don’t forget that we do have to enable the MIDI Input Track switch in Live's MIDI preferences dialog, so that our notes get passed along to the track). Not too difficult, was it? Of course, I wouldn’t expect to see official support for Note Mode on the APC40 from Ableton or Akai anytime soon, if only because thousands of units are already out there, without any red stenciling to tell users which button to press to enable Note Mode – and they might get confused. Well, that, and it might hurt sales of the APC20.

Undo/Redo

Now, before we sign off, we’ll take a minute and re-map the Tempo Nudge buttons to Undo and Redo. It just so happens that the OpenLabs scripts have a nice SpecialTransportComponent module, which provides methods for setting Undo, Redo, and Back to Start (BTS) buttons; we’ll copy the required code over to our equivalent ShiftableTransportComponent module. Here is the Undo code (Redo is almost identical):

def set_undo_button(self, undo_button):
        assert isinstance(undo_button, (ButtonElement,
                                        type(None)))
        if (undo_button != self._undo_button):
            if (self._undo_button != None):
                self._undo_button.remove_value_listener(self._undo_value)
            self._undo_button = undo_button
            if (self._undo_button != None):
                self._undo_button.add_value_listener(self._undo_value)
            self.update()

And the callback (featuring some more LiveAPI calls):

def _undo_value(self, value):
        assert (self._undo_button != None)
        assert (value in range(128))
        if self.is_enabled():
            if ((value != 0) or (not self._undo_button.is_momentary())):
                if self.song().can_undo:
                    self.song().undo()

Conclusion

Well, that’s it for now. We’ve shown that implementing Note Mode on the APC40 is relatively straight-forward, and does not in fact require a firmware update. We’ve also demonstrated that much can be accomplished simply by extending Framework-based scripts and subclasses. Our custom tweaks and mappings do not require templates or add-ons, and best part is that they are free – free as in free beer! Cheers.

Hanz Petrov
May 13, 2010

hanz.petrov
at gmail.com

PostScript: It would seem that many (if not all) of the Live MIDI remote scripts, for all of the various control surfaces, were coded by Jan B. over at Ableton. His code is a pleasure to work with and fun to learn from - my appreciation goes out to Jan and to the entire Ableton team.