Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 210 additions & 0 deletions abletonosc/arrangement.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
from typing import Tuple, Any
from .handler import AbletonOSCHandler
import Live
import logging

logger = logging.getLogger("abletonosc")


class ArrangementHandler(AbletonOSCHandler):
def __init__(self, manager):
super().__init__(manager)
self.class_identifier = "arrangement"

def init_api(self):
def create_midi_clip(params: Tuple[Any]):
"""
Create an empty MIDI clip in the arrangement view.
/live/arrangement/create_midi_clip (track, start_time, length)
Requires Live 12.2+
"""
track, track_id = self._resolve_track(params[0])
start_time = float(params[1])
length = float(params[2])

track.create_midi_clip(start_time, length)

logger.info("Created arrangement MIDI clip on track %s at beat %.1f, length %.1f" %
(track_id, start_time, length))
return (track_id, start_time, length)

def duplicate_to_arrangement(params: Tuple[Any]):
"""
Copy a session clip to the arrangement at a specific beat position.
/live/arrangement/duplicate_to_arrangement (track, clip_slot_index, destination_time)
"""
track, track_id = self._resolve_track(params[0])
clip_slot_index = int(params[1])
destination_time = float(params[2])

if not hasattr(track, 'clip_slots'):
raise ValueError("Track %s has no clip slots" % track_id)

clip_slot = track.clip_slots[clip_slot_index]

if not clip_slot.has_clip:
raise ValueError("No clip in slot %d on track %s" % (clip_slot_index, track_id))

clip = clip_slot.clip
track.duplicate_clip_to_arrangement(clip, destination_time)

logger.info("Duplicated session clip (track %s, slot %d) to arrangement at beat %.1f" %
(track_id, clip_slot_index, destination_time))
return (track_id, clip_slot_index, destination_time)

def delete_clip(params: Tuple[Any]):
"""
Delete an arrangement clip.
/live/arrangement/delete_clip (track, clip_index)
"""
track, track_id = self._resolve_track(params[0])
clip_index = int(params[1])

clips = track.arrangement_clips

if clip_index >= len(clips):
raise ValueError("Clip index %d out of range (track has %d arrangement clips)" %
(clip_index, len(clips)))

clip = clips[clip_index]
track.delete_clip(clip)

logger.info("Deleted arrangement clip %d on track %s" % (clip_index, track_id))
return (track_id, clip_index)

def get_clips(params: Tuple[Any]):
"""
List arrangement clips for a track.
/live/arrangement/get/clips (track)
Returns: (track_id, name, start_time, length, is_midi, ...)
"""
track, track_id = self._resolve_track(params[0])
clips = track.arrangement_clips

result = [track_id]
for clip in clips:
try:
result.append(clip.name)
result.append(clip.start_time)
result.append(clip.length)
result.append(1 if clip.is_midi_clip else 0)
except Exception as e:
logger.warning("Error reading arrangement clip: %s" % e)
continue

return tuple(result)

def get_notes(params: Tuple[Any]):
"""
Get MIDI notes from an arrangement clip.
/live/arrangement/get/notes (track, clip_index, [start_pitch, pitch_span, start_time, time_span])
"""
track, track_id = self._resolve_track(params[0])
clip_index = int(params[1])
clips = track.arrangement_clips

if clip_index >= len(clips):
raise ValueError("Clip index %d out of range" % clip_index)

clip = clips[clip_index]
if not clip.is_midi_clip:
raise ValueError("Clip at index %d is not a MIDI clip" % clip_index)

if len(params) >= 6:
from_pitch = int(params[2])
pitch_span = int(params[3])
from_time = float(params[4])
time_span = float(params[5])
else:
from_pitch = 0
pitch_span = 128
from_time = 0.0
time_span = clip.length

notes = clip.get_notes_extended(from_pitch, pitch_span, from_time, time_span)

result = [track_id, clip_index]
for note in notes:
result.append(note.pitch)
result.append(note.start_time)
result.append(note.duration)
result.append(note.velocity)
result.append(1 if note.mute else 0)

return tuple(result)

def add_notes(params: Tuple[Any]):
"""
Add MIDI notes to an arrangement clip.
/live/arrangement/add/notes (track, clip_index, pitch, start, duration, velocity, mute, ...)
"""
track, track_id = self._resolve_track(params[0])
clip_index = int(params[1])
clips = track.arrangement_clips

if clip_index >= len(clips):
raise ValueError("Clip index %d out of range" % clip_index)

clip = clips[clip_index]
if not clip.is_midi_clip:
raise ValueError("Clip at index %d is not a MIDI clip" % clip_index)

note_params = params[2:]
notes = []
for i in range(0, len(note_params), 5):
if i + 4 >= len(note_params):
break
spec = Live.Clip.MidiNoteSpecification(
pitch=int(note_params[i]),
start_time=float(note_params[i + 1]),
duration=float(note_params[i + 2]),
velocity=float(note_params[i + 3]),
mute=bool(int(note_params[i + 4]))
)
notes.append(spec)

if notes:
clip.add_new_notes(tuple(notes))
logger.info("Added %d notes to arrangement clip %d on track %s" %
(len(notes), clip_index, track_id))

return (track_id, clip_index, len(notes))

def remove_notes(params: Tuple[Any]):
"""
Remove notes from an arrangement clip by range.
/live/arrangement/remove/notes (track, clip_index, [from_pitch, pitch_span, from_time, time_span])
"""
track, track_id = self._resolve_track(params[0])
clip_index = int(params[1])
clips = track.arrangement_clips

if clip_index >= len(clips):
raise ValueError("Clip index %d out of range" % clip_index)

clip = clips[clip_index]
if not clip.is_midi_clip:
raise ValueError("Clip at index %d is not a MIDI clip" % clip_index)

if len(params) >= 6:
from_pitch = int(params[2])
pitch_span = int(params[3])
from_time = float(params[4])
time_span = float(params[5])
else:
from_pitch = 0
pitch_span = 128
from_time = 0.0
time_span = clip.length

clip.remove_notes_extended(from_pitch, pitch_span, from_time, time_span)
logger.info("Removed notes from arrangement clip %d on track %s" % (clip_index, track_id))
return (track_id, clip_index)

self.osc_server.add_handler("/live/arrangement/create_midi_clip", create_midi_clip)
self.osc_server.add_handler("/live/arrangement/duplicate_to_arrangement", duplicate_to_arrangement)
self.osc_server.add_handler("/live/arrangement/delete_clip", delete_clip)
self.osc_server.add_handler("/live/arrangement/get/clips", get_clips)
self.osc_server.add_handler("/live/arrangement/get/notes", get_notes)
self.osc_server.add_handler("/live/arrangement/add/notes", add_notes)
self.osc_server.add_handler("/live/arrangement/remove/notes", remove_notes)
21 changes: 17 additions & 4 deletions abletonosc/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,23 @@ def __init__(self, manager):
def init_api(self):
def create_device_callback(func, *args, include_ids: bool = False):
def device_callback(params: Tuple[Any]):
track_index, device_index = int(params[0]), int(params[1])
device = self.song.tracks[track_index].devices[device_index]
track, track_id = self._resolve_track(params[0])
device_index = int(params[1])
device = track.devices[device_index]
if (include_ids):
rv = func(device, *args, params[0:])
rv = func(device, *args, tuple([track_id, device_index] + list(params[2:])))
else:
rv = func(device, *args, params[2:])

if rv is not None:
return (track_index, device_index, *rv)
return (track_id, device_index, *rv)

return device_callback

methods = [
]
properties_r = [
"can_have_chains",
"class_name",
"name",
"type"
Expand Down Expand Up @@ -140,3 +142,14 @@ def device_get_parameter_name(device, params: Tuple[Any] = ()):
self.osc_server.add_handler("/live/device/get/parameter/name", create_device_callback(device_get_parameter_name))
self.osc_server.add_handler("/live/device/start_listen/parameter/value", create_device_callback(device_get_parameter_value_listener, include_ids = True))
self.osc_server.add_handler("/live/device/stop_listen/parameter/value", create_device_callback(device_get_parameter_remove_value_listener, include_ids = True))

#--------------------------------------------------------------------------------
# Device: Chain discovery (bridge to ChainHandler)
#--------------------------------------------------------------------------------
def device_get_num_chains(device, params: Tuple[Any] = ()):
try:
return (len(device.chains) if hasattr(device, 'chains') else 0,)
except Exception:
return (0,)

self.osc_server.add_handler("/live/device/get/num_chains", create_device_callback(device_get_num_chains))
47 changes: 47 additions & 0 deletions abletonosc/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,53 @@ def _stop_listen(self, target, prop, params: Optional[Tuple[Any]] = ()) -> None:
else:
self.logger.warning("No listener function found for property: %s (%s)" % (prop, str(params)))

def _has_chains(self, device):
"""Check if device has chains (works for Drum Racks where can_have_chains is False)."""
try:
return hasattr(device, 'chains') and len(device.chains) > 0
except Exception:
return False

def _resolve_track(self, track_param):
"""
Resolve a track parameter to (track_object, identifier).

Accepts:
- int or numeric string: self.song.tracks[index] → (track, int)
- "master": self.song.master_track → (track, "master")
- "return_0", "return_1", ...: self.song.return_tracks[n] → (track, "return_N")
- "return_A", "return_B", ...: letter mapped to index → (track, "return_N")

Returns: (track_object, identifier)
Raises: ValueError if track_param cannot be resolved
"""
param = track_param
if isinstance(param, (int, float)):
index = int(param)
return self.song.tracks[index], index

param_str = str(param)

if param_str.lower() in ("master", "main"):
return self.song.master_track, "master"

if param_str.lower().startswith("return_"):
suffix = param_str[7:]
if suffix.isdigit():
index = int(suffix)
elif len(suffix) == 1 and suffix.isalpha():
index = ord(suffix.upper()) - ord("A")
else:
raise ValueError("Invalid return track identifier: %s" % param_str)
return self.song.return_tracks[index], "return_%d" % index

# Fallback: try as numeric index
try:
index = int(param_str)
return self.song.tracks[index], index
except (ValueError, IndexError):
raise ValueError("Cannot resolve track: %s" % param_str)

def _clear_listeners(self):
"""
Clears all listener functions, to prevent listeners continuing to report after a reload.
Expand Down
Loading