How to make a control surface for Ableton

Gabriel Yshay
12 min readMay 12, 2024

The ARA controller mk3

Before we start evaluate how sturdy is your mental health. This was one of the most frustrating projects I did in my life, but it's done and I'll share with you how I did it.

What is a Control Surface?

Control Surfaces are a python script that allows for a hardware to interact natively with ableton. Meaning you could play the set, create clips, add notes, manipulate devices without any midi map.

If you own a Launchpad, Launch Control, APC 40, or something like that you have used a Control Surface.

Ableton actually allows you to create your own and drag it to the User Scripts folder and it will work. The catch is: its absurdly hard and there is no documentation or instructions. So here is the documentation of a successful project.

Resources

To interact with ableton there is a ableton API. The only documentation is this unofficial one . It allows access to the Live Object Model (LOM). Before starting try to do the interactions you plan doing using AbletonOSC and write down all the endpoints you'll want to use.

Then we need examples of Control Surfaces, you can decompile the ones that come with Ableton, people have done that for you. There is an exploration of the framework API that has many useful perspectives. Here is a small tutorial (checkout the PDFs)

3 Objects

A Control Surface relies on 3 things: A ControlSurface, some Elements and some Components.

The ControlSurface is the center of everything. It is responsible for attaching Elements to Components.

A Element represents a hardware piece, like an encoder, a button, a matrix or a screen.

A Component represents a behaviour. Like drum rack, or the Ableton's Mixer, or a StepSequencer. Ableton implements many of these, but I couldn't use them, so I made my owns. You'll find Ableton's inside the lib in ableton.v3.control_surface.components . Thats inside the decompiled control surfaces.

First class

So lets start: we'll create a folder inside Ableton/User Library/Remote Scripts/ with the name of your controller. Then we just add a __init__.py file with the following.

import logging
import os

from ableton.v3.control_surface import (
ControlSurface,
ControlSurfaceSpecification,
create_skin,
)

from . import control

logger = logging.getLogger("your_controller_name")


class Specification(ControlSurfaceSpecification):
control_surface_skin = create_skin(skin=control.Skin)


def create_instance(c_instance):
return ARA(Specification, c_instance=c_instance)


class YourController(ControlSurface):
def __init__(self, *a, **k):
super().__init__(*a, **k)

self.log_level = "info"

self.start_logging()

self.show_message("Controler: init mate")
logger.info("Controler: init started ...")

def setup(self):
super().setup()
self.init()

def init(self):
logger.info("init started:")
with self.component_guard():
logger.info(" adding sking")
self._skin = create_skin(skin=ara.Skin, colors=ara.Rgb)

def start_logging(self):
module_path = os.path.dirname(os.path.realpath(__file__))
log_dir = os.path.join(module_path, "logs")
if not os.path.exists(log_dir):
os.mkdir(log_dir, 0o755)
log_path = os.path.join(log_dir, "ara.log")
self.log_file_handler = logging.FileHandler(log_path)
self.log_file_handler.setLevel(self.log_level.upper())
formatter = logging.Formatter("(%(asctime)s) [%(levelname)s] %(message)s")
self.log_file_handler.setFormatter(formatter)
logger.addHandler(self.log_file_handler)

def stop_logging(self):
logger.removeHandler(self.log_file_handler)

def disconnect(self):
self.show_message("Disconnecting...")
logger.info("Disconnecting...")
self.stop_logging()
super().disconnect()

This code does nothing until now, it just creates an instance of your control surface with a Specification class, a Skin (responsible for lights) and logging.

Logging

This is the only way I managed to make work to debug and make the process less terrible.

The logs are in

C:\Documents and Settings\<user>\Application Data\Ableton\Live x.x.x\Preferences\Log.txt in windows and

/Users/<user>/Library/Preferences/Ableton/Live x.x.x/Log.txt on Mac.

tail -f Log.txt to keep the logs streaming.

Elements

Create a folder for your source code next to the init file. There create a Elements.py file. You need to create a class called Elements, it needs to inherit ElementsBase and it needs to define each piece of hardware and its midi message/channel.

From here on we'll look at my project specific files and you'll do the job of generalising to your controller.

Here is the elements.py

from ableton.v3.control_surface import MIDI_NOTE_TYPE, ElementsBase, MapMode
from functools import partial


class Elements(ElementsBase):
def __init__(self, *a, **k):
super().__init__(*a, **k)
add_button_matrix = partial(
(self.add_button_matrix),
msg_type=MIDI_NOTE_TYPE,
led_channel=0,
is_rgb=True,
is_momentary=True,
)
add_button_matrix([range(52, 68)], base_name="Steps_Buttons", channels=0)

add_button_matrix(
[list(range(36, 44))], base_name="Instruments_Buttons", channels=0
)

add_button_matrix([list(range(44, 48))], base_name="Page_Buttons", channels=0)

self.add_button(48, "Shift_Button", channel=0, msg_type=MIDI_NOTE_TYPE)
self.add_button(49, "Double_Button", channel=0, msg_type=MIDI_NOTE_TYPE)
self.add_button(50, "Fill_Button", channel=0, msg_type=MIDI_NOTE_TYPE)
self.add_button(51, "Mute_Button", channel=0, msg_type=MIDI_NOTE_TYPE)

self.add_encoder_matrix(
[range(36, 44)],
base_name="Encoder_Matrix",
channels=0,
is_feedback_enabled=True,
needs_takeover=True,
map_mode=MapMode.LinearBinaryOffset,
)

My buttons will fire a Midi note on on channel 0 with a note number for each button. Buttons 36 to 44 are a matrix. From 52 to 68 as well. Pages are from 44 to 48. All of them have LEDs next to them, when we wish to light the LEDs we set the Buttons color and ableton will send the on-off message.

My Components

I needed 3 components: Sequencer, Decoder and NoteEditor.

Sequencer is the center of the logic.

from typing import Optional, Tuple, Any, Callable, List
from ableton.v2.control_surface import Component # type: ignore
import logging
from ableton.v2.base import listens # type: ignore

logger = logging.getLogger("ara")

START_DRUM_NOTE = 36

class Sequencer:
update_leds_callback: Callable[[], None]
update_inst_leds_callback: Callable[[], None]

def __init__(self, *a, **k):
super().__init__(*a, **k)
self.logger = logging.getLogger("ara")
self.logger.info("sequencer init called")
self.reset()

def reset(self):
self.state = [[False for _ in range(16 * 4)] for _ in range(8)]
self.acc = [[False for _ in range(16 * 4)] for _ in range(8)]
self.instrument_mute = [False for _ in range(8)]
self._selected_instrument = 0
self._selected_page = 0
self._current_step = 0
self._current_length = 16

def set_editor(self, editor):
self._editor = editor

def set_device(self, device):
self._device = device

@property
def selected_instrument(self):
return self._selected_instrument

def _get_step_idx(self, idx):
return idx + self._selected_page * 16

@selected_instrument.setter
def selected_instrument(self, value):
assert 0 <= value < 8
self._selected_instrument = value
if self.update_leds_callback:
self.update_leds_callback()

if self.update_inst_leds_callback:
self.update_inst_leds_callback()

def toggle_step(self, idx):
self.state[self._selected_instrument][self._get_step_idx(idx)] = not self.state[
self._selected_instrument
][self._get_step_idx(idx)]
logger.info(f"toggled step {idx} for instrument {self._selected_instrument}")

if self.state[self._selected_instrument][self._get_step_idx(idx)]:
self._editor.create_note(
self._selected_instrument + START_DRUM_NOTE,
(self._get_step_idx(idx)) / 4,
self.acc[self._selected_instrument][self._get_step_idx(idx)],
)
else:
self._editor.delete_note(
self._selected_instrument + START_DRUM_NOTE,
(self._get_step_idx(idx)) / 4,
)

if self.update_leds_callback:
self.update_leds_callback()

def get_current_page(self) -> Tuple[List[bool], List[bool]]:
return (
self.state[self._selected_instrument][
self._selected_page * 16 : (self._selected_page + 1) * 16
],
self.acc[self._selected_instrument][
self._selected_page * 16 : (self._selected_page + 1) * 16
],
)

def get_instruments(self) -> Tuple[List[bool], List[bool]]:
return [i == self._selected_instrument for i in range(8)], self.instrument_mute

def set_position(self, position: float):
step = int(4 * position)
if step != self._current_step:
self._current_step = step
if self.update_leds_callback:
self.update_leds_callback()

def set_length(self, length):
self._editor.set_length(self._get_step_idx(length) / 4)
self._current_length = self._get_step_idx(length)
self.update_leds_callback()

def erase_instrument(self, instrument):
self._editor.delete_all_note_steps_for_pitch(instrument + START_DRUM_NOTE)
for i in range(4 * 16):
self.state[instrument][i] = False
self.acc[instrument][i] = False
self.update_leds_callback()

def double_loop(self):
for instrument in range(8):
for i in range(self._current_length):
self.state[instrument][i + self._current_length] = self.state[
instrument
][i]
self.acc[instrument][i + self._current_length] = self.acc[instrument][i]

self._current_length *= 2

self._editor.double_loop()
self.update_leds_callback()

def toggle_acc_for_step(self, idx):
self.acc[self._selected_instrument][self._get_step_idx(idx)] = not self.acc[
self._selected_instrument
][self._get_step_idx(idx)]
if self.state[self._selected_instrument][self._get_step_idx(idx)]:
self.toggle_step(
idx
) # toggle will do the translating so no need to do it here
self.toggle_step(idx)

def set_page(self, page):
self._selected_page = page

def mute_instrument(self, instrument):
self._device.drum_pads[instrument + START_DRUM_NOTE].mute = (
not self._device.drum_pads[instrument + START_DRUM_NOTE].mute
)
self.instrument_mute[instrument] = self._device.drum_pads[
instrument + START_DRUM_NOTE
].mute
self.update_inst_leds_callback()

def clear_all(self):
self.reset()
self.update_leds_callback()

def fill_current_instrument(self):
for i in range(self._current_length):
self.state[self._selected_instrument][i] = True
self.acc[self._selected_instrument][i] = False
self._editor.create_note(
self._selected_instrument + START_DRUM_NOTE,
(i) / 4,
False,
)

self.update_leds_callback()
self.update_inst_leds_callback()

The sequencer exposes a few functions for changing the sequence itself and some functions to set the necessary callbacks/components. set_editor sets the NoteEditor to actually interact with the notes. set_device sets a device, (like a DrumRack), when found.

Now, the decoder is the component responsible for getting the midi messages and calling callbacks. Its also responsible for updating the LEDs colors.

from ableton.v3.control_surface import Component
from ableton.v3.control_surface.controls import (
ButtonControl,
control_matrix,
EncoderControl,
)
from ableton.v3.live import liveobj_changed, liveobj_valid
from Live.Clip import MidiNoteSpecification # type: ignore
import logging
from .sequencer import Sequencer
from typing import Optional, Any

logger = logging.getLogger("ara")


class StepButtonControl(ButtonControl):

class State(ButtonControl.State):
x = property(lambda self: self.coordinate[1])
y = property(lambda self: self.coordinate[0])
is_active = False


class StepDecoder(Component):
""" """

shift_button = ButtonControl(color=None)
double_button = ButtonControl(color=None)
mute_button = ButtonControl(color=None)
fill_button = ButtonControl(color=None)

matrix = control_matrix(StepButtonControl)
instrument_matrix = control_matrix(StepButtonControl)
page_matrix = control_matrix(StepButtonControl)
encoder_matrix = control_matrix(EncoderControl)

def __init__(
self,
name="Step_Decoder",
sequencer: Optional[Sequencer] = None,
*a,
**k,
):
logger.info(f"args=[{a}], kwargs=[{k}]")
super().__init__(name=name, *a, **k)

self._sequencer = sequencer

@property
def sequencer(
self,
):
return self._sequencer

@sequencer.setter
def sequencer(self, _sequencer):
self._sequencer = _sequencer
if self._sequencer is not None:
self._sequencer.update_leds_callback = self.update_leds
self._sequencer.update_inst_leds_callback = self.update_instrument_leds

def set_device(self, device):
self._device = device

def _pot_to_vol(self, pot):
return (pot + 1) / 2

@encoder_matrix.value
def encoder_turn(self, value, encoder):
if self._device is None:
return

self._device.drum_pads[36 + encoder.index].chains[
0
].mixer_device.volume.value = self._pot_to_vol(value)

def set_matrix(self, matrix):
self.matrix.set_control_element(matrix)

@matrix.value
def matrix_click(self, _, state):
idx = state.x
if self._sequencer is None:
return
if self.mute_button.is_pressed:
self._sequencer.toggle_acc_for_step(idx)
elif self.shift_button.is_pressed:
self._sequencer.set_length(idx + 1)
else:
self._sequencer.toggle_step(idx)

@instrument_matrix.value
def instruments_click(self, _, state):
idx = state.x
if self._sequencer is None:
return

if self.mute_button.is_pressed:
self._sequencer.mute_instrument(idx)
self.update_instrument_leds()

elif self.shift_button.is_pressed:
self._sequencer.erase_instrument(idx)
self.update_leds()
else:
self._sequencer.selected_instrument = idx
self.update_instrument_leds()

@page_matrix.value
def page_clic(self, _, state):
idx = state.x

if self._sequencer is None:
return

self._sequencer.set_page(idx)
self.update_page_leds()

@shift_button.pressed
def on_shift_pressed(self, *a, **k):
self.update_leds()

@shift_button.released
def on_shift_released(self, *a):
self.update_leds()

@double_button.pressed
def on_double(self, *a):
assert self._sequencer
if self.shift_button.is_pressed:
self.try_lock_callback()
else:
self._sequencer.double_loop()

@shift_button.value
def on_shift_pressed2(self, *a, **k):
logger.info(f"shift pressed: {self.shift_button.is_pressed}")

@fill_button.pressed
def on_fill(self, *a):
if self.shift_button.is_pressed:
self._sequencer.fill_current_instrument()


@mute_button.pressed
def on_mute(self, *a):
if self.shift_button.is_pressed:
self._sequencer.clear_all()

def refresh_all_leds(self):
self.update_leds()
self.update_instrument_leds()
self.update_page_leds()

def update_leds(self):
if not self._sequencer:
return

steps, accs = self._sequencer.get_current_page()

# StepButton.[Active][Step][Acc]
for idx, (state, value, acc) in enumerate(zip(self.matrix, steps, accs)):
seq_idx = self._sequencer._get_step_idx(idx)
if self.shift_button.is_pressed:
if seq_idx < self._sequencer._current_length:
state.color = "StepButton.ActiveStepAcc"
else:
state.color = "StepButton.Inactive"

else:
if seq_idx == self._sequencer._current_step:
is_step = "Step"
else:
is_step = ""

if value:
is_active = "Active"
else:
is_active = "Inactive"

if acc:
is_acc = "Acc"
else:
is_acc = ""

state.color = "StepButton." + is_active + is_step + is_acc


def update_instrument_leds(self):
if not self._sequencer:
return

instruments, mutted = self._sequencer.get_instruments()

for state, value, is_mutted in zip(self.instrument_matrix, instruments, mutted):
if is_mutted:
state.color = "InstrumentButton.Mutted"
elif value:
state.color = "InstrumentButton.Active"
else:
state.color = "InstrumentButton.Inactive"


def update_page_leds(self):
if self._sequencer is None:
return
pages = [False] * 4
pages[self._sequencer._selected_page] = True

for state, value in zip(self.page_matrix, pages):
state.color = "DefaultButton.On" if value else "DefaultButton.Off"

The first thing we notice in this class are the Control objects. They represent the element that was connected to this component. We have 3 button matrices, 1 encoder matrix and 4 normal buttons (without feedback).

To get a click of a button I have used the @x.value and @x.pressed decorators, which will call the function decorated. The matrix pressed callbacks receive as 2nd argument a state object. It has the coordinate of the button, which we'll use.

Next, to set the color of the LEDs we loop through the button matrix, and we set the button.color property to a specific string. That string refers to the Skin class which we'll look later. Ableton will send a MIDI message to set the color automatically when it changes.

Finally the NoteEditor

import logging
from typing import Any, Optional

from ableton.v3.control_surface import Component
from ableton.v3.live import liveobj_changed, liveobj_valid
from Live.Clip import MidiNoteSpecification # type: ignore

from .sequencer import Sequencer

logger = logging.getLogger("ara")



class NoteEditorComponent(Component):
def __init__(
self,
name="Note_Editor",
sequencer_clip: Any = None,
sequencer: Optional[Sequencer] = None,
*a,
**k,
):
logger.info(f"args=[{a}], kwargs=[{k}]")
super().__init__(name=name, *a, **k)

self._clip_notes = []
self._clip = None
self._sequencer_clip = sequencer_clip
self.set_clip(self._sequencer_clip)

assert sequencer is not None
self._sequencer = sequencer

def create_note(self, pitch, time, acc):
assert self._has_clip()
assert self._clip is not None
velocity = 127 if acc else 90
note = MidiNoteSpecification(
pitch=pitch,
start_time=time,
duration=0.25,
velocity=velocity,
mute=False,
)
self._clip.add_new_notes((note,))
self._clip.deselect_all_notes()

def delete_note(self, pitch: int, time: float):
assert self._has_clip()
assert self._clip is not None

self._clip.remove_notes_extended(
from_time=time, from_pitch=pitch, time_span=0.25, pitch_span=1
)

def set_length(self, length: float):
assert self._has_clip()
assert self._clip is not None

self._clip.loop_end = length

def double_loop(self):
assert self._clip is not None

self._clip.duplicate_loop()

def delete_all_note_steps_for_pitch(self, pitch):
assert self._clip is not None

self._clip.remove_notes_extended(
from_time=0, from_pitch=pitch, time_span=100, pitch_span=1
)

def get_clip_notes(self):
assert self._has_clip()
assert self._clip is not None

return self._clip.get_notes_extended(
from_time=0, from_pitch=0, time_span=16, pitch_span=128
)

def set_clip(self, clip):
if liveobj_changed(clip, self._clip):
self._clip = clip

def _has_clip(self):
return self._clip is not None and liveobj_valid(self._clip)

def clear_clip(self):
assert self._has_clip()
assert self._clip is not None

self._clip.remove_notes_extended(0, 0, 100, 128)

The first method here is the set_clip, which just receives an instance of a Live clip, the main class will be responsible for getting it.

This object is a Live Object Model object, it has all properties described there. So we can add and delete notes.

With these 3 components we have almost all functionality we need. Last lets just see our __init__.py fully configured.

Final ControlSurface

import importlib
import logging
import os
import traceback

from ableton.v2.base import listens
from ableton.v3.control_surface import (
ControlSurface,
ControlSurfaceSpecification,
create_skin,
)
from ableton.v3.control_surface.components import (
create_sequencer_clip,
)

from . import ara


logger = logging.getLogger("ara")


def create_mappings(control_surface):
mappings = {}

mappings["StepDecoder"] = dict(
matrix="steps_buttons",
instrument_matrix="instruments_buttons",
shift_button="shift_button",
mute_button="mute_button",
double_button="double_button",
page_matrix="page_buttons",
fill_button="fill_button",
encoder_matrix="encoder_matrix",
)

return mappings


class Specification(ControlSurfaceSpecification):
elements_type = ara.Elements
control_surface_skin = create_skin(skin=ara.Skin)
create_mappings_function = create_mappings
component_map = {
"Note_Editor": ara.NoteEditorComponent,
"StepDecoder": ara.StepDecoder,
}


def create_instance(c_instance):
return ARA(Specification, c_instance=c_instance)


class ARA(ControlSurface):
def __init__(self, *a, **k):
super().__init__(*a, **k)

self.log_level = "info"

self.start_logging()

self.show_message("ARA: init mate")
logger.info("ARA: init started ...")

def setup(self):
super().setup()
self.init()

def reload_imports(self):
try:
importlib.reload(ara.sequencer)
importlib.reload(ara.note_editor)
importlib.reload(ara.decoder)

except Exception as e:
exc = traceback.format_exc()
logging.warning(exc)

def init(self):
self.reload_imports()
self.target_clip = None
self.target_track = None
self.target_device = None
logger.info("init started:")
with self.component_guard():
logger.info(" adding sking")
self._skin = create_skin(skin=ara.Skin, colors=ara.Rgb)

logger.info(" adding listeners")

self._ARA__on_selected_track_changed.subject = self.song.view

logger.info(" adding listeners done")

logger.info("creating components")
logger.info(" creating sequencer")
self.create_sequencer()

logger.info(" creating note editor")
self.create_note_editor()

self._sequencer.set_editor(self._note_editor)

self.component_map["StepDecoder"].sequencer = self._sequencer

logger.info(f" ara is enabled= {self._enabled}")
self.component_map["StepDecoder"].refresh_all_leds()
self.component_map["StepDecoder"].try_lock_callback = self.try_lock_track

self.get_target_track()
self.create_clip()

def get_target_track(self) -> None:

self.target_track = None
tracks = self.song.tracks
for track in tracks:
if track.name.lower() == "ara":
self.target_track = track
break

self.create_clip()

def create_clip(self):
if self.target_track is not None:
self._ARA__on_devices_changed.subject = self.target_track

logger.info(f"Target track: {self.target_track.name}")
logger.info("creating clip")
self.target_clip = create_sequencer_clip(self.target_track, 4)
self._note_editor.set_clip(self.target_clip)

self._ARA__on_playhead_move.subject = self.target_clip
self.try_grab_device()
self._sequencer.clear_all()

logger.info("creating clip done")

def try_grab_device(self):
if self.target_track is None:
self.target_device = None
return

logger.info(f"devices={self.target_track.devices}")
device = self.target_track.devices[0]

if device.can_have_drum_pads:
self.target_device = device
self._sequencer.set_device(device)
self.component_map["StepDecoder"].set_device(device)

def try_lock_track(self):
logger.info(f"try lock track {self.song.view.selected_track.name}")
self.target_track = self.song.view.selected_track
self.create_clip()

def create_note_editor(self):
self._note_editor = ara.NoteEditorComponent(
is_enabled=False,
sequencer_clip=self.target_clip,
sequencer=self._sequencer,
)

def create_sequencer(self):
self._sequencer = ara.Sequencer()

def start_logging(self):
"""
Start logging to a local logfile (logs/abletonosc.log),
and relay error messages via OSC.
"""
module_path = os.path.dirname(os.path.realpath(__file__))
log_dir = os.path.join(module_path, "logs")
if not os.path.exists(log_dir):
os.mkdir(log_dir, 0o755)
log_path = os.path.join(log_dir, "ara.log")
self.log_file_handler = logging.FileHandler(log_path)
self.log_file_handler.setLevel(self.log_level.upper())
formatter = logging.Formatter("(%(asctime)s) [%(levelname)s] %(message)s")
self.log_file_handler.setFormatter(formatter)
logger.addHandler(self.log_file_handler)

def stop_logging(self):
logger.removeHandler(self.log_file_handler)

def disconnect(self):
self.show_message("Disconnecting...")
logger.info("Disconnecting...")
self.stop_logging()
super().disconnect()

@listens("selected_track")
def __on_selected_track_changed(self):
logger.info(f"selected track changed: {self.song.view.selected_track.name}")

@listens("playing_position")
def __on_playhead_move(self):

if self.target_clip is not None:
self._sequencer.set_position(self.target_clip.playing_position)

@listens("devices")
def __on_devices_changed(self):
self.try_grab_device()

Before the Control Surface code we start at the Specification class, there we define which will be the Elements, the Skin, we call that create_mappings method, that will assign each Element (by name, but lower case) to a component (also by name). Finally there is a dict with each Component.

Lets start at the initmethod. We start calling the create_skin method, it just binds our skin class to the control surface. Then there is this line

self._ARA__on_selected_track_changed.subject = self.song.view

This configures a listener. Listeners are a crucial part of remote scripts, they allow us to listen for properties changing on Ableton. Here we want to do something when the selected track changes. That is a property of the visual part of the song , so the .subject of our listener will be the song.view .

The listener itself is defined close to the end of the file

@listens("selected_track")
def __on_selected_track_changed(self):
logger.info(f"selected track changed: {self.song.view.selected_track.name}")

The string "selected_track" is the property that is being listened to. In the callback for the listener we access the selected_track name. We don't do anything with it here, but its important to demonstrate the principle.

Next the init method calls the create sequencer, which we can see its just instantiating the Sequencer class.

Then we instantiate the NoteEditor, maybe giving it the target_clip if selected or None. Then we give the NoteEditor to the Sequencer .

Then there is this line.

self.component_map["StepDecoder"].sequencer = self._sequencer

component_map was created by the Specification and it has an entry for each of our components.

Then we refresh the LEDs thought the Decoder and give it the try lock device callback, this method will check if there is a selected track, if it has a clip and if there is a drum rack on it. This method will be called by a key combination or automatically when the controller starts.

The get_target_track method tries to find a track called ara.

The create_clip firstly checks if we have a target track, if we do we'll listen for device changes on that track with the listener devices in the end of the file. Then with the track in hands we'll create a new clip and assign it to the editor. Then we create a listener for the playhead position, to draw a playhead with the Controller's LEDs. Lastly we try to grab the device. This might fail if there is no device or the device type is not a DrumRack.

Skin

To define LEDs feedback we need to define a skin which will map a Color to a variable that can be referenced throughout the code.

from ableton.v3.control_surface.elements import SimpleColor

LED_CHANNEL = 0
class Rgb:
BLACK = SimpleColor(0, channel=LED_CHANNEL)
WHITE = SimpleColor(7, channel=LED_CHANNEL)
RED = SimpleColor(0b100, channel=LED_CHANNEL)
GREEN = SimpleColor(0b010, channel=LED_CHANNEL)
BLUE = SimpleColor(0b001, channel=LED_CHANNEL)

CYAN = SimpleColor(0b011, channel=LED_CHANNEL)
YELLOW = SimpleColor(0b110, channel=LED_CHANNEL)
PURPLE = SimpleColor(0b101, channel=LED_CHANNEL)

BLACK_w = SimpleColor(0b1000, channel=LED_CHANNEL)
WHITE_w = SimpleColor(0b1111, channel=LED_CHANNEL)
RED_w = SimpleColor(0b1100, channel=LED_CHANNEL)
GREEN_w = SimpleColor(0b1010, channel=LED_CHANNEL)
BLUE_w = SimpleColor(0b1001, channel=LED_CHANNEL)

CYAN_w = SimpleColor(0b1011, channel=LED_CHANNEL)
YELLOW_w = SimpleColor(0b1110, channel=LED_CHANNEL)
PURPLE_w = SimpleColor(0b1101, channel=LED_CHANNEL)

These colors reflect what my controller expects. I designed it to receive only primary and secondary colors, without shades. There is also a _w, or weak, color variation. With these colors in hand we define the Skin.

from .colors import Rgb


class Skin:
class DefaultButton:
On = Rgb.RED
Off = Rgb.BLACK

class StepButton:
# StepButton.[Active][Step][Acc]
ActiveStep = Rgb.WHITE_w
ActiveStepAcc = Rgb.WHITE
InactiveStep = Rgb.CYAN_w
InactiveStepAcc = Rgb.CYAN
Active = Rgb.RED_w
ActiveAcc = Rgb.RED
Inactive = Rgb.BLACK_w
InactiveAcc = Rgb.BLACK

class InstrumentButton:
Active = Rgb.YELLOW
Inactive = Rgb.BLACK
Mutted = Rgb.BLUE

Developing your own

Using the given elements is relatively straight forward, but developing components or using ableton's might not be. So you'll need to look at a lot of examples. Use the decompiled scripts for that. Specially Push (not the 2), ATOM, ATOMSQ, FANTOM, APC64, APC_Key_25, Komplete_Kontrol_S_mk3 and a few others. I also used the Launchpad95 remote script, but its proprietary so I cant share the code.

Its dificult but do try and help me document what you learn. This is the complete code

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Gabriel Yshay
Gabriel Yshay

Written by Gabriel Yshay

Data Science, Hardware and Music

Responses (3)

Write a response

Hey there, i just developed my own user remote script.
And first of all i wnat to thank you for your article. Though it truely helped me, I wished i'd had a proper documentation of the user remote scripts api. I spend some hours digging within the…

--

Verry interesting article! What did you use as hardware device?

--

Hi, thank you for posting this. I'm trying to follow this as a guide to create my first basic remote control script in Ableton (Live 11) but getting some errors in Ableton's Log.txt when starting Ableton with my remote script.
I'm wondering if this…

--