1- from enum import Enum
1+ from itertools import count
22from logging import getLogger
3+ from typing import Any
34
4- from ableton .v3 .base import task
5+ from ableton .v3 .base import depends , task
56from ableton .v3 .control_surface .components import (
67 SessionComponent as SessionComponentBase ,
78)
9+ from ableton .v3 .control_surface .controls import ButtonControl
810
9- from .live import lazy_attribute
11+ from .live import lazy_attribute , listens , listens_group
1012
1113logger = getLogger (__name__ )
1214
1315
14- # We want to be able to enable/disable the stop all clips button depending on whether
15- # clips are playing, and show a triggered indication while clips are stopping. The Live
16- # API doesn't expose the necessary properties/events, so we need to compute the status
17- # manually.
18- class StopAllClipsStatus (Enum ):
19- enabled = "enabled"
20- disabled = "disabled"
21- triggered = "triggered"
22-
23-
2416class SessionComponent (SessionComponentBase ):
2517 _tasks : task .TaskGroup # type: ignore
2618
19+ # This already exists on the parent, but we need to override its `pressed` listener.
20+ #
21+ # It would be nice if we could make the color more dynamic (e.g. off when no tracks
22+ # playing and blinking while tracks are stopping), but the base component only
23+ # invokes update methods on changes to the stop-clips status for tracks within the
24+ # session ring, whereas we'd need to update the color on changes to the status for
25+ # any track in the set.
26+ stop_all_clips_button : Any = ButtonControl (
27+ color = "Session.StopAllClips" , pressed_color = "Session.StopAllClipsPressed"
28+ )
29+
30+ @depends (session_ring = None )
31+ def __init__ (
32+ self ,
33+ * a ,
34+ name = "Session" ,
35+ session_ring = None ,
36+ scene_component_type = None ,
37+ clip_slot_component_type = None ,
38+ clipboard_component_type = None ,
39+ ** k ,
40+ ):
41+ super ().__init__ (
42+ * a ,
43+ name = name ,
44+ session_ring = session_ring ,
45+ scene_component_type = scene_component_type ,
46+ clip_slot_component_type = clip_slot_component_type ,
47+ clipboard_component_type = clipboard_component_type ,
48+ ** k ,
49+ )
50+
51+ # Updated when the stop-all-clips button is pressed, and again when all clips
52+ # have stopped. Tracks whether the button should be blinking.
53+ self .__is_stopping_all_clips : bool = False
54+
55+ assert self .song
56+ self .__on_tracks_changed .subject = self .song
57+ self ._reassign_track_listeners ()
58+
2759 def set_launch_selected_scene_button (self , button ):
2860 self .selected_scene ().set_launch_button (button )
2961
@@ -40,11 +72,47 @@ def set_clip_launch_buttons(self, buttons):
4072 )
4173 scene .clip_slot (x ).set_launch_button (button )
4274
43- def _update_stop_clips_led (self , index ):
44- super ()._update_stop_clips_led (index )
45-
46- # We don't want to compute the triggered state for every LED update, since it's
47- # O(n) with the number of tracks. Instead, use a task to throttle updates.
75+ @stop_all_clips_button .pressed # type: ignore
76+ def stop_all_clips_button (self , _ ):
77+ assert self .song
78+ self .notify (self .notifications .Session .stop_all_clips )
79+ self .song .stop_all_clips ()
80+
81+ # We'll get notified via the fired/playing slot index listeners once the stop
82+ # has actually been triggered. The listeners get invoked regardless of wether
83+ # any clips were actually already playing.
84+ self .__is_stopping_all_clips = True
85+
86+ def update (self ):
87+ super ().update ()
88+ self ._update_stop_all_clips_led ()
89+
90+ @listens_group ("fired_slot_index" )
91+ def __on_any_fired_slot_index_changed (self , _ ):
92+ self .__throttled_update_stop_all_clips_led ()
93+
94+ @listens_group ("playing_slot_index" )
95+ def __on_any_playing_slot_index_changed (self , _ ):
96+ self .__throttled_update_stop_all_clips_led ()
97+
98+ @listens ("tracks" )
99+ def __on_tracks_changed (self ):
100+ self ._reassign_track_listeners ()
101+
102+ # The Stop All Clips button needs to get updates from every track in the set to
103+ # check when it can stop appearing as triggered.
104+ def _reassign_track_listeners (self ):
105+ assert self .song
106+ assert self .__on_any_fired_slot_index_changed
107+
108+ tracks = self .song .tracks
109+ self .__on_any_fired_slot_index_changed .replace_subjects (tracks , count ())
110+ self .__on_any_playing_slot_index_changed .replace_subjects (tracks , count ())
111+
112+ def __throttled_update_stop_all_clips_led (self ):
113+ # We don't want to compute the triggered state for every listener update, since
114+ # the computation is O(n) with the number of tracks. Instead, use a task to
115+ # throttle updates.
48116 if self ._update_stop_all_clips_led_task .is_killed :
49117 # Perform the update once at the head of the delay to get immediate LED
50118 # feedback. In general this should already set the button state correctly.
@@ -62,22 +130,38 @@ def _update_stop_all_clips_led_task(self):
62130 return update_stop_all_clips_led_task
63131
64132 def _update_stop_all_clips_led (self ):
65- status = self ._stop_all_clips_status ()
66133 color = "Session.StopAllClips"
67- enabled = True
68134
69- if status == StopAllClipsStatus .disabled :
70- enabled = False
71- elif status == StopAllClipsStatus .triggered :
72- color = "Session.StopAllClipsTriggered"
73-
74- if self .stop_all_clips_button .enabled != enabled :
75- self .stop_all_clips_button .enabled = enabled
135+ # Check for the triggered state, or clear the stopping status if the triggered
136+ # state is no longer active.
137+ #
138+ # Note the Stop All Clips button in the Live UI has slightly different behavior:
139+ # if the transport is playing but no clips are playing when it's pressed, it
140+ # will blink until playback reaches the next launch quanitization point
141+ # (e.g. the next bar). With our logic in this case, the button won't ever reach
142+ # a blinking state. There doesn't appear to be a good way to listen for reaching
143+ # the next quanitzation point during playback.
144+ if self .__is_stopping_all_clips :
145+ if self ._is_stop_all_clips_maybe_triggered ():
146+ color = "Session.StopAllClipsTriggered"
147+ else :
148+ self .__is_stopping_all_clips = False
76149
77150 if self .stop_all_clips_button .color != color :
78151 self .stop_all_clips_button .color = color
79152
80- def _stop_all_clips_status (self ) -> StopAllClipsStatus :
153+ # Returns true iff:
154+ #
155+ # - at least one clip has stop triggered
156+ # - no clips are playing without a triggered stop
157+ #
158+ # These conditions can also be met without pressing "Stop All Clips" (e.g. when
159+ # playing a single clip and then stopping it), so this logic should generally be
160+ # combined with tracking of actual presses of the stop-all-clips button.
161+ def _is_stop_all_clips_maybe_triggered (self ) -> bool :
162+ # This gets the full list of tracks in the set. Unclear what the difference is
163+ # between this and `song.tracks`, but this seems to be more canonical for this
164+ # context.
81165 assert self ._session_ring
82166 tracks_to_use = self ._session_ring .tracks_to_use ()
83167
@@ -90,15 +174,12 @@ def _stop_all_clips_status(self) -> StopAllClipsStatus:
90174 if track .playing_slot_index >= 0 and track .fired_slot_index != - 2 :
91175 # Once we find one playing clip that isn't
92176 # stopping, we can return immediately.
93- return StopAllClipsStatus . enabled
177+ return False
94178 elif track .fired_slot_index == - 2 :
95- # We need to keep iterating to see if there are any
96- # other clips which are playing and not triggered to
97- # stop.
179+ # Once we find a stopping clip, we need to keep iterating to see if
180+ # there are any other clips which are playing and not triggered to stop.
98181 is_stop_triggered = True
99182
100- return (
101- StopAllClipsStatus .triggered
102- if is_stop_triggered
103- else StopAllClipsStatus .disabled
104- )
183+ # If we get here, no clips are playing without a triggered stop. Return whether
184+ # at least one clip is currently triggered.
185+ return is_stop_triggered
0 commit comments