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
9 changes: 6 additions & 3 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -1208,23 +1208,26 @@ def bind_current_pedalboard(self):
# any real time settings
self._controller_manager.bind(self.current)

def _redraw_after_binding(self, controller: Controller, is_footswitch: bool) -> None:
if is_footswitch:
def _redraw_after_binding(self, controller: Controller | None, is_footswitch: bool) -> None:
if is_footswitch and controller is not None:
# Footswitch: redraw just that one switch, not the whole board.
self.lcd.update_footswitch(controller)
else:
self.lcd.draw_analog_assignments(self.current.analog_controllers)

def _add_learned_binding_row(
self, plugin: Plugin, param: Parameter, controller: Controller, old_binding: str | None
self, plugin: Plugin, param: Parameter, controller: Controller | None, old_binding: str | None
) -> None:
layer = self._controller_manager.effective_table.layers[0]
if old_binding is not None:
_remove_binding_row(layer, old_binding)
if controller is None:
return
if isinstance(controller, Footswitch):
cls, event_kind = ControlClass.FOOTSWITCH, EventKind.PRESS
else:
cls, event_kind = ControlClass.ANALOG, EventKind.ROTATE
assert param.binding is not None
layer.add(
BindingDecl(
control=ControlRef(cls=cls, id=param.binding),
Expand Down
6 changes: 6 additions & 0 deletions pistomp/footswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ def dispatch_key(self) -> str:
return f"{self.midi_channel}:{self.midi_CC}"
return f"fs:{self.id}"

@override
def unbind_from_parameter(self) -> None:
super().unbind_from_parameter()
self.display_label = None
self.set_category(None)

@property
def drives_display(self) -> bool:
"""True when unbound: no inbound echo will arrive, so the press updates
Expand Down
35 changes: 29 additions & 6 deletions pistomp/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,9 +209,10 @@ def hide_fullscreen_panel(self) -> None:
def _apply_midi_binding(
self, instance: str, symbol: Symbol, binding: str, binding_range: tuple[float, float] | None = None
) -> None:
# A MIDI mapping was learned in mod-ui. Update the matching parameter's
# binding and wire its hardware controller so the LCD reflects it without
# a pedalboard reload. Idempotent: replayed connect-dump maps are no-ops.
# A MIDI mapping was learned or cleared in mod-ui. Update the matching
# parameter's binding and wire/unwire its hardware controller so the LCD
# reflects it without a pedalboard reload. Idempotent: replayed connect-dump
# maps are no-ops.
if self._current is None:
return
plugin = self.current.pedalboard.find_plugin(instance)
Expand All @@ -226,9 +227,31 @@ def _apply_midi_binding(
param.set_binding_range(binding_range)
if param.binding == binding:
return

old_binding = param.binding
controller = self.hardware.controllers.get(binding)
old_controller = self.hardware.controllers.get(old_binding) if old_binding is not None else None

if old_controller is not None and old_binding != binding:

@sastraxi sastraxi Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be fine after the addition of binding_range to fix a bug with the "Advanced" binding features of MOD-UI (we were ignoring the parameter range before), but it would be good to have a test that explicitly makes sure a different range bound on the same parameter still works as we expect it to. Sorry for the churn.

old_controller.unbind_from_parameter()
if old_controller in plugin.controllers:
plugin.controllers.remove(old_controller)
if isinstance(old_controller, Footswitch):
plugin.has_footswitch = any(
isinstance(c, Footswitch) for c in plugin.controllers
)
elif isinstance(old_controller, (AnalogMidiControl, EncoderController)):
key = "%s:%s" % (plugin.instance_id, param.name)
self.current.analog_controllers.pop(key, None)

if controller is None:
param.binding = None
self._add_learned_binding_row(plugin, param, None, old_binding)
if old_controller is not None:
is_footswitch = isinstance(old_controller, Footswitch)
self._redraw_after_binding(old_controller, is_footswitch)
return

# Externally-routed controls aren't bound to plugin parameters; board
# load ignores such bindings (_bind_plugin_parameters) and the live
# learn must agree, or the control's MidiCcEffect row shadows the
Expand All @@ -239,7 +262,7 @@ def _apply_midi_binding(
f"{binding} (routed to {self.hardware.external_port_name(controller)}) - ignoring"
)
return
old_binding = param.binding

param.binding = binding
is_footswitch = self._bind_controller_to_param(plugin, param, controller)
self._add_learned_binding_row(plugin, param, controller, old_binding)
Expand All @@ -263,13 +286,13 @@ def _bind_controller_to_param(self, plugin: "Plugin", param: "Parameter", contro
self.current.analog_controllers[key] = display_info
return False

def _redraw_after_binding(self, controller, is_footswitch):
def _redraw_after_binding(self, controller: Controller | None, is_footswitch: bool) -> None:
# Refresh the LCD after a learned binding. Subclasses redraw at their
# own granularity.
raise NotImplementedError()

def _add_learned_binding_row(
self, plugin: "Plugin", param: "Parameter", controller: Controller, old_binding: str | None
self, plugin: "Plugin", param: "Parameter", controller: Controller | None, old_binding: str | None
) -> None:
# Add a table row for a live-learned binding so dispatch and badges
# reflect it without a pedalboard reload. MOD subclasses override;
Expand Down
4 changes: 4 additions & 0 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,10 @@ def update_footswitch(self, footswitch):
# Binding may be new (e.g. MIDI learn) — reflect label + color.
footswitch.set_display_label(self.footswitch_label(footswitch, slot_w))
wfs.color = accent_color_for(footswitch.category)
wfs.action = self.footswitch_event
else:
wfs.color = None
wfs.action = None
wfs.toggle(not footswitch.toggled)
wfs.label = footswitch.get_display_label() or ""
wfs.refresh()
Expand Down
2 changes: 1 addition & 1 deletion plugins/transport/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from __future__ import annotations

from common.parameter import Parameter, Symbol
from common.parameter import Parameter
from modalapi.plugin_customization import PluginCustomization
from modalapi.pedalboard import BPM_SYMBOL, BPB_SYMBOL, ROLLING_SYMBOL
from plugins.customization import register
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
114 changes: 114 additions & 0 deletions tests/v3/test_midi_learn.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,3 +307,117 @@ def test_v3_midi_learn_adds_table_row_for_encoder(v3_system: SystemFixture, make
assert isinstance(effect, ParamEffect)
assert effect.plugin is plugin
assert effect.symbol == Symbol("gain")


def test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd(
v3_system: SystemFixture, make_plugin, make_parameter, snapshot
):
"""Removing a MIDI mapping in MOD-UI (channel=-1, controller=-1) unbinds the encoder,
removes the analog controller assignment, drops the binding row, and reverts LCD displays."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge

assert handler.current and handler.lcd

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

gain = make_parameter("Gain", "noise", value=0.5)
plugin = make_plugin("noise", bypassed=False, has_footswitch=False, parameters={"gain": gain})
handler.current.pedalboard.plugins = [plugin]
handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
handler.lcd.draw_main_panel()

# Learn binding to Tweak1
ws_bridge.inject(f"midi_map /graph/noise gain {channel} {cc} 0.0 1.0")
handler.poll_ws_messages()

assert gain.binding == binding_id
assert enc1.parameter is gain
assert f"noise:{gain.name}" in handler.current.analog_controllers
snapshot("bound")

# Unmap binding in MOD-UI
ws_bridge.inject("midi_map /graph/noise gain -1 -1 0.0 1.0")
handler.poll_ws_messages()

assert gain.binding is None
assert enc1.parameter is None
assert f"noise:{gain.name}" not in handler.current.analog_controllers

rows = handler.effective_table.layers[0].rows.get((ControlClass.ANALOG, EventKind.ROTATE), [])
matched = [r for r in rows if r.control.id == binding_id]
assert len(matched) == 0
snapshot("unbound")


def test_v3_midi_unlearn_footswitch_clears_binding(v3_system: SystemFixture, make_plugin, snapshot):
"""Removing a footswitch MIDI mapping in MOD-UI clears footswitch state and has_footswitch flag."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge

assert handler.current and handler.lcd

fs0 = hw.footswitches[0]
binding_id = _binding_for(hw, fs0)
channel, cc = binding_id.split(":")

plugin = make_plugin("noise", bypassed=False, has_footswitch=False)
handler.current.pedalboard.plugins = [plugin]
handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
handler.lcd.draw_main_panel()

ws_bridge.inject(f"midi_map /graph/noise :bypass {channel} {cc} 0.0 1.0")
handler.poll_ws_messages()
assert fs0.parameter is plugin.parameters[BYPASS_SYMBOL]
assert plugin.has_footswitch is True
snapshot("bound")

# Unmap in MOD-UI
ws_bridge.inject("midi_map /graph/noise :bypass -1 -1 0.0 1.0")
handler.poll_ws_messages()
assert fs0.parameter is None
assert fs0.display_label is None
assert fs0.category is None
assert plugin.has_footswitch is False
snapshot("unbound")


def test_v3_midi_learn_updated_binding_range_on_same_parameter(
v3_system: SystemFixture, make_plugin, make_parameter
):
"""Re-addressing an already bound parameter to a different sub-range on the same CC
updates the parameter's binding range and endpoints without bailing early."""
handler = v3_system.handler
hw = v3_system.hw
ws_bridge = v3_system.ws_bridge

assert handler.current

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

gain = make_parameter("Gain", "noise", value=0.5)
plugin = make_plugin("noise", bypassed=False, has_footswitch=False, parameters={"gain": gain})
handler.current.pedalboard.plugins = [plugin]

# Initial mapping: sub-range 0.0 .. 0.5
ws_bridge.inject(f"midi_map /graph/noise gain {channel} {cc} 0.0 0.5")
handler.poll_ws_messages()

assert gain.binding == f"{channel}:{cc}"
assert (gain.minimum, gain.maximum) == (0.0, 0.5)
assert enc1.parameter is gain

# Updated mapping on SAME binding: sub-range 0.2 .. 0.8
ws_bridge.inject(f"midi_map /graph/noise gain {channel} {cc} 0.2 0.8")
handler.poll_ws_messages()

assert gain.binding == f"{channel}:{cc}"
assert (gain.minimum, gain.maximum) == (0.2, 0.8)
assert enc1.parameter is gain


Loading