Skip to content
Merged
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
4 changes: 4 additions & 0 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1483,6 +1483,10 @@ def effective_table(self) -> ContextStack:
render from."""
return self._controller_manager.effective_table

@property
def binding_revision(self) -> int:
return self._controller_manager.binding_revision

#
# Parameter Stuff
#
Expand Down
9 changes: 8 additions & 1 deletion pistomp/controller_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,11 +66,17 @@ class ControllerManager:
def __init__(self, hardware: "Hardware"):
self._hw = hardware
self.effective_table = ContextStack(layers=[])
self._binding_revision = 0

@property
def binding_revision(self) -> int:
return self._binding_revision

def bind(self, current: Current | None) -> None:
"""Create the runtime associations for the active pedalboard."""
"""Create the runtime associations of the active pedalboard."""
self.effective_table = ContextStack(layers=[])
if current is None:
self._binding_revision += 1
return

current.close()
Expand All @@ -84,6 +90,7 @@ def bind(self, current: Current | None) -> None:
self._bind_encoder_longpress(layer)
self._bind_footswitch_actions(layer)
self.effective_table = ContextStack(layers=[layer])
self._binding_revision += 1

def _bind_plugin_parameters(self, current: Current, pedalboard_layer: ContextLayer) -> None:
"""Bind controllers referenced by plugin parameters."""
Expand Down
5 changes: 5 additions & 0 deletions pistomp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ def lcd_poll_divisor(self) -> int:
# it dynamically (e.g. when the tuner panel is visible).
return 20

@property
def binding_revision(self) -> int:
"""Revision of the handler's effective hardware bindings."""
return 0

def noop(self):
pass

Expand Down
40 changes: 28 additions & 12 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ def __init__(self, cwd, handler: "Modhandler", flip=False, display=None, spi_spe
self._subtitle_desc = "" # last selection description seen
self._subtitle_changed_at = 0.0 # monotonic time of last nav change
self.w_parameter_dialogs = {}
self._parameter_dialog_binding_revision: int | None = None

# panels
self.pstack = PanelStack(display, image_format="RGB", use_dimming=True)
Expand Down Expand Up @@ -281,6 +282,7 @@ def poll_updates(self):
self._poll_updates()

def _poll_updates(self):
self._refresh_parameter_dialog_badges()
for d in self.w_parameter_dialogs.values():
d.tick()
if self.pstack.current is self.main_panel:
Expand Down Expand Up @@ -725,6 +727,29 @@ def _external_tweak_badge_number(self, param: Parameter) -> int | None:
return self._encoder_badge_for_control_id(control_id)
return None

def _parameter_dialog_badge_state(self, parameter: Parameter) -> tuple[int | None, BadgeGlyph | None]:
plugin = (
next((p for p in self.current.pedalboard.plugins if p.instance_id == parameter.instance_id), None)
if self.current is not None
else None
)
if plugin is not None:
number = self.tweak_badge_number(plugin, parameter)
elif parameter.binding is not None:
number = self._external_tweak_badge_number(parameter)
else:
number = None
return number, _TWEAK_BADGES.get(number) if number is not None else None

def _refresh_parameter_dialog_badges(self) -> None:
revision = self.handler.binding_revision
if revision == self._parameter_dialog_binding_revision:
return
self._parameter_dialog_binding_revision = revision
for dialog in self.w_parameter_dialogs.values():
if isinstance(dialog, Parameterdialog) and dialog.parent is not None:
dialog.set_tweak_badge(*self._parameter_dialog_badge_state(dialog.parameter))

def _active_analog_rotate_rows(self) -> Iterator[tuple[BindingDecl, str]]:
"""the rows a tweak badge can attribute to a real encoder"""
if self.handler is None:
Expand Down Expand Up @@ -792,6 +817,7 @@ def open_parameter_dialog(self, context: EditContext, timeout=None) -> Parameter

parameter = context.parameter
assert parameter.type not in (Type.ENUMERATION, Type.TOGGLED)

d = Parameterdialog(
self.pstack,
context,
Expand All @@ -800,18 +826,8 @@ def open_parameter_dialog(self, context: EditContext, timeout=None) -> Parameter
auto_destroy=True,
timeout=timeout,
)
plugin = (
next((p for p in self.current.pedalboard.plugins if p.instance_id == parameter.instance_id), None)
if self.current is not None
else None
)
if plugin is not None:
n = self.tweak_badge_number(plugin, parameter)
elif parameter.binding is not None:
n = self._external_tweak_badge_number(parameter)
else:
n = None
d.set_tweak_badge(n, _TWEAK_BADGES.get(n) if n is not None else None)
d.set_tweak_badge(*self._parameter_dialog_badge_state(parameter))

self.w_parameter_dialogs[context.cache_key] = d
self.pstack.push_panel(d)
return d
Expand Down
28 changes: 20 additions & 8 deletions plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ class PluginPanel(Panel, Generic[TState], ABC):
_btn_bypass: Button | None
_badge_fn: Callable[[Parameter], str | None] | None
_model_dirty: bool
_seen_binding_revision: int
_unsub_model: Callable[[], None] | None

def _init_plugin_state(
Expand All @@ -132,6 +133,7 @@ def _init_plugin_state(
self._param_queue = {}
self._badge_fn = None
self._model_dirty = False
self._seen_binding_revision = handler.binding_revision
self._unsub_model = None

def _badge_for(self, symbol: Symbol) -> str | None:
Expand All @@ -144,8 +146,22 @@ def _badge_bypass(self) -> None:
if self._btn_bypass is None:
return
badge_char = self._badge_for(BYPASS_SYMBOL)
if badge_char is not None:
self._btn_bypass.set_badge(BadgeGlyph(badge_char))
self._btn_bypass.set_badge(BadgeGlyph(badge_char) if badge_char is not None else None)

def _refresh_binding_badges(self) -> None:
self._badge_bypass()

def _on_binding_revision(self, revision: int) -> None:
"""Reconcile presentation derived from the effective bindings."""
self._refresh_binding_badges()

def _check_binding_revision(self) -> None:
revision = self.handler.binding_revision
if revision == self._seen_binding_revision:
return
self._seen_binding_revision = revision
self._on_binding_revision(revision)


def _start_observing(self) -> None:
"""Subscribe to plugin param changes. Call at the end of a child
Expand Down Expand Up @@ -277,17 +293,13 @@ def set_param(self, symbol: Symbol, value: float) -> None:
param.preview(value)

def tick(self) -> None:
"""Drain the coalesced parameter queue, then reconcile from the model
if any parameter changed under us since the last tick.

Subclasses that override ``tick()`` **must** call ``super().tick()`` so
queued sends are not lost and the model-dirty drain runs.
"""
"""Drain queued writes and reconcile model and binding changes."""
self._flush_param_queue()
if self._model_dirty:
self._model_dirty = False
self.apply_state(self.snapshot_state())
self._refresh_bypass_style()
self._check_binding_revision()

def _flush_param_queue(self) -> None:
for symbol, value in self._param_queue.items():
Expand Down
14 changes: 14 additions & 0 deletions plugins/parameter_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,12 @@ def __init__(
self._value_font = value_font
self._bypassed: bool = False

def set_badge_char(self, badge_char: str | None) -> None:
if badge_char == self._badge_char:
return
self._badge_char = badge_char
self.refresh()

def _param(self) -> Parameter | None:
return self._owner.plugin.parameters.get(self.symbol)

Expand Down Expand Up @@ -631,6 +637,14 @@ def edit_symbol(self, symbol: Symbol, rotations: int, multiplier: float = 1.0) -
return row.on_encoder_rotation(rotations, multiplier)
return super().edit_symbol(symbol, rotations, multiplier)

def _refresh_binding_badges(self) -> None:
super()._refresh_binding_badges()
for widget in self._slot_widgets:
badge_char = self._badge_for(widget.slot.symbol)
widget.set_badge(BadgeGlyph(badge_char) if badge_char is not None else None)
for row in self._list_rows:
row.set_badge_char(self._badge_for(row.symbol))

def _refresh_bypass_style(self) -> None:
super()._refresh_bypass_style()
bypassed = self.plugin.is_bypassed()
Expand Down
1 change: 1 addition & 0 deletions tests/test_plugin_panels.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ def send_parameter(self, instance_id: str, symbol: str, value: float) -> bool:
class FakeHandler:
def __init__(self):
self.ws_bridge = FakeWsBridge()
self.binding_revision = 0
self.locked: set[tuple[str, str]] = set()

def is_symbol_locked(self, instance_id: str, symbol: str) -> bool:
Expand Down
133 changes: 133 additions & 0 deletions tests/v3/test_midi_learn.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol
from common.parameter_editing import EditContext
from tests.types import SystemFixture
from plugins.parameter_window import ParameterWindow
from uilib.misc import InputEvent
from uilib.parameterdialog import Parameterdialog

LOG_PORT: PortInfo = {
"shortName": "HP",
Expand Down Expand Up @@ -333,6 +336,136 @@ def test_v3_midi_learn_adds_table_row_for_encoder(v3_system: SystemFixture, make
assert effect.symbol == Symbol("gain")


def test_v3_midi_learn_updates_open_plugin_menu_badge(v3_system: SystemFixture, make_plugin, make_parameter):
"""An open ParameterWindow reflects MIDI learn and unlearn without reopening."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge
lcd = handler.lcd

assert handler.current and lcd

enc1 = next(e for e in hw.encoders if e.id == 1)
channel, cc = _binding_for(hw, enc1).split(":")

params = {
"gain": make_parameter("Gain", "noise"),
"tone": make_parameter("Tone", "noise"),
"mix": make_parameter("Mix", "noise"),
"drive": make_parameter("Drive", "noise"),
}
target = make_parameter("Depth", "noise")
params["depth"] = target
plugin = make_plugin("noise", bypassed=False, parameters=params)
handler.current.pedalboard.plugins = [plugin]
lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
lcd.draw_main_panel()

lcd.main_panel.sel_widget(lcd.w_plugins[0])
lcd.main_panel.input_event(InputEvent.LONG_CLICK)
handler.poll_lcd_updates()
panel = lcd.pstack.current
assert isinstance(panel, ParameterWindow)
row = next(row for row in panel._list_rows if row.symbol == target.symbol)
assert row._badge_char is None

ws_bridge.inject(f"midi_map /graph/noise depth {channel} {cc} 0.0 1.0")
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert row._badge_char == "1"

ws_bridge.inject("midi_map /graph/noise depth -1 -1 0.0 1.0")
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert row._badge_char is None


def test_v3_midi_unlearn_and_relearn_updates_open_plugin_menu_badge(
v3_system: SystemFixture, make_plugin, make_parameter
):
"""An open ParameterWindow drops and restores a badge as MIDI learn changes."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge
lcd = handler.lcd

assert handler.current and lcd

enc1 = next(e for e in hw.encoders if e.id == 1)
channel, cc = _binding_for(hw, enc1).split(":")

params = {
"gain": make_parameter("Gain", "noise"),
"tone": make_parameter("Tone", "noise"),
"mix": make_parameter("Mix", "noise"),
"drive": make_parameter("Drive", "noise"),
}
target = make_parameter("Depth", "noise")
params["depth"] = target
plugin = make_plugin("noise", bypassed=False, parameters=params)
handler.current.pedalboard.plugins = [plugin]
lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
lcd.draw_main_panel()

learn = f"midi_map /graph/noise depth {channel} {cc} 0.0 1.0"
ws_bridge.inject(learn)
handler.poll_ws_messages()

lcd.main_panel.sel_widget(lcd.w_plugins[0])
lcd.main_panel.input_event(InputEvent.LONG_CLICK)
handler.poll_lcd_updates()
panel = lcd.pstack.current
assert isinstance(panel, ParameterWindow)
row = next(row for row in panel._list_rows if row.symbol == target.symbol)
assert row._badge_char == "1"

ws_bridge.inject("midi_map /graph/noise depth -1 -1 0.0 1.0")
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert row._badge_char is None

ws_bridge.inject(learn)
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert row._badge_char == "1"


def test_v3_midi_unlearn_updates_open_parameter_dialog_badge(v3_system: SystemFixture, make_plugin, make_parameter):
"""An open Parameterdialog drops and restores its MIDI-learn badge live."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge
lcd = handler.lcd

assert handler.current and lcd

enc1 = next(e for e in hw.encoders if e.id == 1)
channel, cc = _binding_for(hw, enc1).split(":")
gain = make_parameter("Gain", "noise")
plugin = make_plugin("noise", bypassed=False, parameters={"gain": gain})
handler.current.pedalboard.plugins = [plugin]
lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
lcd.draw_main_panel()

learn = f"midi_map /graph/noise gain {channel} {cc} 0.0 1.0"
ws_bridge.inject(learn)
handler.poll_ws_messages()

dialog = lcd.open_parameter_editor(EditContext(gain, handler.parameter_ui_value_commit))
assert isinstance(dialog, Parameterdialog)
assert dialog._tweak_id == 1

ws_bridge.inject("midi_map /graph/noise gain -1 -1 0.0 1.0")
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert dialog._tweak_id is None

ws_bridge.inject(learn)
handler.poll_ws_messages()
handler.poll_lcd_updates()
assert dialog._tweak_id == 1


def test_v3_midi_learn_reroutes_an_already_bound_pedalboard(v3_system: SystemFixture, make_plugin, make_parameter):
"""A parameter changes from WebSocket transport to encoder CC after MOD learns a mapping.
The route is derived per commit, so a binding learned after bind cannot leave a stale route."""
Expand Down