diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index a9cfbfc1f..745f066ab 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -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), diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index 10f32c31b..2be7023ab 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -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 diff --git a/pistomp/handler.py b/pistomp/handler.py index 3c1b5fd6b..1c22c4e91 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -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) @@ -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: + 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 @@ -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) @@ -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; diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 03e5f6929..8f8e2dad6 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -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() diff --git a/plugins/transport/__init__.py b/plugins/transport/__init__.py index ef376e81b..46f210ea0 100644 --- a/plugins/transport/__init__.py +++ b/plugins/transport/__init__.py @@ -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 diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/bound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/bound.png new file mode 100644 index 000000000..62631f9f5 Binary files /dev/null and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/bound.png differ diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/unbound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/unbound.png new file mode 100644 index 000000000..ec5009b59 Binary files /dev/null and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_encoder_clears_binding_and_updates_lcd/unbound.png differ diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/bound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/bound.png new file mode 100644 index 000000000..295d95bdf Binary files /dev/null and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/bound.png differ diff --git a/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png new file mode 100644 index 000000000..0132255fa Binary files /dev/null and b/tests/snapshots/v3/test_midi_learn/test_v3_midi_unlearn_footswitch_clears_binding/unbound.png differ diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index cee8ed1be..e55e1554b 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -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 + +