Skip to content

Commit e808b53

Browse files
authored
feat: add display notifications for some transport actions (#10)
1 parent 05b6969 commit e808b53

File tree

10 files changed

+346
-57
lines changed

10 files changed

+346
-57
lines changed

control_surface/consts.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

control_surface/display.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def _toggle_text(text: str, toggle_state: bool):
5959
"redo": "Redo",
6060
"selected_track_arm": "ArmT",
6161
"session_record": "SRec",
62-
"stop_all_clips": "StCl",
62+
"stop_all_clips": "StAl",
6363
"tap_tempo": "TapT",
6464
"undo": "Undo",
6565
}
@@ -133,9 +133,12 @@ class Recording(DefaultNotifications.Recording):
133133
new = _action_texts["new"]
134134

135135
class Scene(DefaultNotifications.Scene):
136-
launch = partial(_scene_notification, prefix="#")
136+
launch = partial(_scene_notification, prefix=">")
137137
select = partial(_scene_notification, prefix="#")
138138

139+
class Session:
140+
stop_all_clips = _action_texts["stop_all_clips"]
141+
139142
class SessionNavigation:
140143
vertical = partial(_right_align_index, "_")
141144
horizontal = partial(_right_align_index, "|")
@@ -160,8 +163,8 @@ class EditTrackControl:
160163
pass
161164

162165
class Transport(DefaultNotifications.Transport):
163-
automation_arm = None # partial(_toggle_text, _action_texts["automation_arm"])
164-
# metronome = partial(_toggle_text, _action_texts["metronome"])
166+
automation_arm = partial(_toggle_text, _action_texts["automation_arm"])
167+
metronome = partial(_toggle_text, _action_texts["metronome"])
165168
midi_capture = _action_texts["capture_midi"]
166169
tap_tempo = lambda t: _right_align("T", int(t)) # noqa: E731
167170

control_surface/live.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
flatten, # noqa: F401
1212
lazy_attribute, # noqa: F401
1313
listens, # noqa: F401
14+
listens_group, # noqa: F401
1415
memoize, # noqa: F401
1516
)

control_surface/live.pyi

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import typing
22

33
from ableton.v2.base import Slot as __Slot
4+
from ableton.v2.base import SlotGroup as __SlotGroup
45

56
T = typing.TypeVar("T")
67

@@ -16,4 +17,7 @@ class lazy_attribute(typing.Generic[T]):
1617
def listens(
1718
event_path: str, *a, **k
1819
) -> typing.Callable[[typing.Callable[..., typing.Any]], __Slot]: ...
20+
def listens_group(
21+
event_path: str, *a, **k
22+
) -> typing.Callable[[typing.Callable[..., typing.Any]], __SlotGroup]: ...
1923
def memoize(function: typing.Callable[..., T]) -> typing.Callable[..., T]: ...

control_surface/recording.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def update(self):
5959

6060

6161
class RecordingComponent(RecordingComponentBase):
62-
# Push-style "New" button.
62+
# Equivalent to Push's "Duplicate" button when used while clips are playing.
6363
capture_and_insert_scene_button: ButtonControl.State = ButtonControl( # type: ignore
6464
color="Recording.CaptureAndInsertScene",
6565
pressed_color="Recording.CaptureAndInsertScenePressed",

control_surface/session.py

Lines changed: 118 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,61 @@
1-
from enum import Enum
1+
from itertools import count
22
from logging import getLogger
3+
from typing import Any
34

4-
from ableton.v3.base import task
5+
from ableton.v3.base import depends, task
56
from 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

1113
logger = 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-
2416
class 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

tests/conftest.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1178,8 +1178,10 @@ def should_be_text(
11781178
text: str,
11791179
device_state: DeviceState,
11801180
):
1181-
assert device_state.display_text is not None
1182-
assert device_state.display_text.strip() == text
1181+
assert device_state.display_text is not None, "Display text not yet set"
1182+
assert (
1183+
device_state.display_text.strip() == text
1184+
), f'Expected display text to be "{text}", but was "{device_state.display_text.strip()}"'
11831185

11841186

11851187
@then(parsers.parse('the display should be scrolling "{text}"'))

tests/test_transport.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from conftest import (
2+
Device,
3+
DeviceState,
4+
cc_action,
5+
get_cc_for_key,
6+
get_color,
7+
stabilize_after_cc_action,
8+
sync,
9+
)
10+
from pytest_bdd import parsers, scenarios, then
11+
from typeguard import typechecked
12+
13+
scenarios("transport.feature")
14+
15+
16+
# Check that pressing a button twice toggles the light on/off, and toggles the
17+
# corresponding display between "+{control}" and "-{control}" respectively, but don't
18+
# care about whether the toggle is initially on or off.
19+
#
20+
# This allows checking the behavior of transport buttons whose state is saved globally
21+
# in Live (i.e. not as part of the Set).
22+
@then(parsers.parse('key {key_number:d} should toggle the "{control}" status'))
23+
@sync
24+
@typechecked
25+
async def should_toggle_control(
26+
key_number: int,
27+
control: str,
28+
device: Device,
29+
device_state: DeviceState,
30+
):
31+
cc = get_cc_for_key(key_number)
32+
33+
# Check the LED status and the popup display immediately after pressing the button,
34+
# and see if it matches the given on/off status for the toggle.
35+
def assert_matches_status_after_press(status: bool):
36+
# All transport toggles are currently solid yellow. We can parametrize this if
37+
# needed in the future.
38+
color = get_color(key_number, device_state)
39+
expected_color = "solid yellow" if status else "off"
40+
assert (
41+
color == expected_color
42+
), f"Expected color to be {expected_color}, but was {color}"
43+
44+
expected_display_text = f'{"+" if status else "-"}{control}'
45+
assert (
46+
device_state.display_text == expected_display_text
47+
), f'Expected display text to be "{expected_display_text}", but was "{device_state.display_text}"'
48+
49+
current_status = False if get_color(key_number, device_state) == "off" else True
50+
51+
for _ in range(2):
52+
await cc_action(cc, "press", device)
53+
await stabilize_after_cc_action(device)
54+
55+
current_status = not current_status
56+
assert_matches_status_after_press(current_status)

tests/track_controls_modes.feature

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ Feature: Track controls modes
197197
# change.
198198
Examples:
199199
| gesture | k1 | k2 | k3 | k4 | k5 | k6 | k7 | k8 | k9 |
200-
| press | LnSc | Rec | Play | Met | SRec | StCl | ArmT | Aut | TapT |
200+
| press | LnSc | Rec | Play | Met | SRec | StAl | ArmT | Aut | TapT |
201201
| long-press | AAr | Undo | CpMD | CpSc | SRec | BaK | Redo | Quan | NwCl |
202202

203203
Scenario: Navigating through track control edit screens

0 commit comments

Comments
 (0)