How to make a control surface for Ableton

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 init
method. 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