"""Status panel additions to AbletonOSC (installed by tools/abletonosc/install.sh on the Tracks Mac). /live/statuspanel/move_note Moves the note of `pitch` that starts at arrangement `beat` on the arrangement MIDI clip of the track called `track name` so it starts at `new beat`, keeping where it ends (the next cue is not touched). Answers (, , , ). Undo in Live (Cmd+Z) puts it back. START's consolidating (ableton_start.py) finds an arrangement clip by (Live's, no returns), (any track of that name when the index has moved) and the it starts at. A track with no name of its own shows its number in Live ("56-Piano" is "5-Piano" in the set file) and its clip's name ("Piano [2026-10-26 113835]" after a consolidate, "Piano (Freeze)" after a flatten), so only the name in between is compared: /live/statuspanel/clip Answers (, , , , , , ). /live/statuspanel/select_clip Selects the clip in the arrangement (Edit > Consolidate then works on it), unfolding the groups it is in (Live cannot select a track inside a folded group). Answers (..., ). /live/statuspanel/refold Folds the groups select_clip unfolded back up. Answers (). /live/statuspanel/drop_tails Deletes the "... (Freeze tail)" clips Flatten Track leaves after a clip, and takes "(Freeze)" out of the track's name (back to Live's own name). Answers (, , , ); deleted is -1 when this Live cannot delete a clip from a script. /live/statuspanel/set_clip [] Sets the clip's transposition, then its warp (and its name). Answers (..., , , ). """ import logging import re import Live logger = logging.getLogger("abletonosc") def register(handler): def move_note(params): song = handler.song name, beat, pitch, new = str(params[0]), float(params[1]), int(params[2]), float(params[3]) track = next((t for t in song.tracks if t.name.strip().upper() == name.strip().upper() and t.has_midi_input), None) moved = 0 for clip in (track.arrangement_clips if track else []): if not (clip.start_time - 1e-6 <= beat < clip.end_time): continue zero = clip.start_time - (clip.loop_start if clip.looping else clip.start_marker) local, new_local = beat - zero, new - zero notes = clip.get_notes_extended(pitch, 1, local - 0.002, 0.004) for n in notes: end = n.start_time + n.duration if new_local < end: n.start_time, n.duration = new_local, end - new_local moved += 1 if moved: clip.apply_note_modifications(notes) break logger.info("statuspanel: %s note %d at %s -> %s: %d moved", name, pitch, beat, new, moved) return name, beat, pitch, moved handler.osc_server.add_handler("/live/statuspanel/move_note", move_note) def bare(n): # "56-Piano [2026-10-26 113835]" and "56-Piano (Freeze)" are the file's "5-Piano" n = re.sub(r"^\s*\d+\s*-\s*", "", str(n)) n = re.sub(r"\[.*?\]|\(freeze\)", " ", n, flags=re.I) return " ".join(n.split()).upper() def find(params): song = handler.song i, name, beat = int(params[0]), bare(params[1]), float(params[2]) ts = list(song.tracks) order = ([ts[i]] if 0 <= i < len(ts) and bare(ts[i].name) == name else []) + \ [t for t in ts if bare(t.name) == name] for t in order: try: arr = t.arrangement_clips except Exception: continue for c in arr: if abs(c.start_time - beat) < 0.01: return t, c return None, None def info(c): if c is None: return 0, 0, 0, "" return 1, int(c.pitch_coarse), int(c.warping), str(c.file_path) def clip(params): _, c = find(params) return (int(params[0]), str(params[1]), float(params[2])) + info(c) unfolded = [] def select_clip(params): t, c = find(params) if c is not None: song = handler.song g = t.group_track if t.is_grouped else None while g is not None: if g.fold_state: g.fold_state = 0 unfolded.append(g) g = g.group_track if g.is_grouped else None view = Live.Application.get_application().view view.show_view("Arranger") view.focus_view("Arranger") song.view.selected_track = t song.view.detail_clip = c view.focus_view("Arranger") logger.info("statuspanel: select %s at %s: %s", params[1], params[2], c is not None) return int(params[0]), str(params[1]), float(params[2]), int(c is not None) def refold(params): n = 0 while unfolded: g = unfolded.pop() try: g.fold_state = 1 n += 1 except Exception: pass return (n,) def set_clip(params): _, c = find(params) if c is not None: c.pitch_coarse = int(params[3]) c.warping = bool(int(params[4])) if len(params) > 5 and str(params[5]): c.name = str(params[5]) logger.info("statuspanel: %s at %s: pitch %s warp %s", params[1], params[2], params[3], params[4]) return (int(params[0]), str(params[1]), float(params[2])) + info(c)[:3] handler.osc_server.add_handler("/live/statuspanel/clip", clip) handler.osc_server.add_handler("/live/statuspanel/select_clip", select_clip) handler.osc_server.add_handler("/live/statuspanel/set_clip", set_clip) handler.osc_server.add_handler("/live/statuspanel/refold", refold) def drop_tails(params): i, name = int(params[0]), bare(params[1]) ts = list(handler.song.tracks) order = ([ts[i]] if 0 <= i < len(ts) and bare(ts[i].name) == name else []) + [t for t in ts if bare(t.name) == name] if not order: return i, str(params[1]), 0, "" t = order[0] n = -1 if hasattr(t, "delete_clip"): n = 0 for c in list(t.arrangement_clips): if c.name.endswith("(Freeze tail)"): t.delete_clip(c) n += 1 if "(Freeze)" in t.name: t.name = "" # Live's own name again, from the renamed clip if "(Freeze)" in t.name: t.name = re.sub(r"\s*\(Freeze\)", "", t.name) logger.info("statuspanel: %s: %d freeze tails deleted, now %s", params[1], n, t.name) return i, str(params[1]), n, str(t.name) handler.osc_server.add_handler("/live/statuspanel/drop_tails", drop_tails)