ControlSurface lifecycle¶
The ControlSurface is the root object of every Remote Script: Live creates exactly one per
enabled controller and drives it through a fixed lifecycle. Knowing the order of these
phases is what separates a script that works from one that crashes on load, leaks listeners,
or leaves LEDs lit after you switch it off. This page walks the cradle-to-grave sequence.
Birth: how Live loads your script¶
Live discovers a Remote Script by folder. Inside that folder, __init__.py exposes a single
entry point:
When you pick the controller in Preferences -> Link/Tempo/MIDI, Live calls
create_instance and hands it c_instance -- the bridge object back into the C++ core. You
pass it straight to the ControlSurface base class; never throw it away, it is how every LOM
call and every MIDI send ultimately reaches Live.
Construction: __init__ and the component guard¶
All setup happens in __init__. The single most important rule: build everything inside the
component guard context manager.
class MyControlSurface(ControlSurface):
def __init__(self, c_instance, *a, **k):
super().__init__(c_instance, *a, **k)
with self.component_guard():
self._create_controls() # wrap the physical Elements
self._create_components() # SessionComponent, MixerComponent, ...
self._create_modes() # wire layers into a ModesComponent
The guard defers side effects until the whole object graph exists, so components can reference each other and listeners are registered safely. Building components outside the guard is the classic source of "half-initialised" bugs.
Order inside the guard matters: create Elements first (they have no dependencies), then Components (which need elements to bind to via layers), then Modes (which arrange components). This mirrors the dependency direction from Components, Layers & Modes.
Going live: the first state push¶
Once constructed, the surface must paint its initial state onto the hardware -- otherwise LEDs and displays show garbage until the user touches something. Two methods drive this:
update()-- asks every enabled component to re-evaluate and re-send its feedback.refresh_state()-- forces a full resend, used after the surface (re)connects or wakes from suspend.
Live also rebuilds the MIDI map at this point (see below), establishing which incoming messages are routed to which elements.
Running: event-driven, never polled¶
After startup the surface does nothing until something happens. It is purely reactive:
- a MIDI message arrives -> dispatched to an element -> to the bound control -> to a component handler (the downstream path);
- a LOM value the surface observes changes -> the listener fires -> the component pushes new feedback out (the upstream path).
There is no per-frame loop you write. If you find yourself wanting one, you almost certainly want a listener instead. The MIDI message flow guide details both directions.
The MIDI map¶
Live does not send every controller message to Python by default. The surface declares a MIDI map -- which notes/CCs it wants -- and Live routes accordingly. Two paths exist:
- direct mapping -- Live forwards the message straight to the mapped LOM target with no Python round-trip (low latency, e.g. a fader to a parameter);
- forwarding -- the message is delivered to your script so a component can interpret it.
When the wiring changes (a mode switch rebinds elements), the map is stale, so the surface
calls request_rebuild_midi_map(). Live then calls back into build_midi_map(...),
where the current elements re-declare their routing. Rebuilds are batched, not immediate --
request one and let Live call you back.
Suspend and resume¶
Live suspends a surface when it loses focus or another surface takes over a shared resource, and resumes it afterwards:
suspend()/disconnect_...-- stop driving the hardware, but keep the object alive.resume()+refresh_state()-- repaint everything, because the hardware state may have drifted while you were suspended.
Locking to a device (can_lock_to_devices, lock_to_device) is a related mechanism: it lets
the surface "follow" the appointed device or pin to a specific one.
Death: disconnect¶
When the user deselects the controller or quits Live, the surface is torn down via
disconnect(). This is not optional cleanup -- it is mandatory:
- remove every LOM listener you added. Orphaned listeners are the number-one cause of crashes and memory growth across script reloads.
- turn the hardware off cleanly -- clear LEDs, blank displays, reset the device to a neutral state so it isn't left glowing.
- call
super().disconnect()so the base class releases its own resources.
A useful mental rule: every add_*_listener in construction needs a matching teardown in
disconnect. The framework helps -- listeners registered through components are cleaned up
when the component is disconnected -- but anything you wire by hand, you unwire by hand.
The lifecycle at a glance¶
create_instance(c_instance)
|
v
__init__ ──> with component_guard(): elements -> components -> modes
|
v
update() / refresh_state() (first paint + build_midi_map)
|
v
RUNNING <──> (MIDI in / listeners out) [suspend() <-> resume() as focus changes]
|
v
disconnect() remove listeners, blank hardware, super().disconnect()
See also: Architecture for the layers this sits on top of, and MIDI message flow for what happens during the RUNNING phase.