diff --git a/modalapi/mod.py b/modalapi/mod.py index 72a73b923..3fc6c5c76 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -31,7 +31,7 @@ from blend.snapshot import SnapshotManager from modalapi.websocket_bridge import AsyncWebSocketBridge -from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, AddPluginMessage, ParamSetMessage, MidiMapMessage, WebSocketMessage +from modalapi.ws_protocol import parse_message, LoadingEndMessage, LoadingStartMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, AddPluginMessage, ParamSetMessage, MidiMapMessage, WebSocketMessage from modalapi.pedalboard_monitor import FileChangeMonitor, read_pedalboard_bundle from pistomp.footswitch import Footswitch @@ -155,6 +155,9 @@ def __init__(self, audiocard, homedir): self.ws_bridge.start() logging.info("WebSocket bridge started") + # Suppress outbound WebSocket messages while a pedalboard change is in flight. + self._is_pedalboard_loading: bool = False + # Callback function map. Key is the user specified name, value is function from this handler # Used for calling handler callbacks pointed to by names which may be user set in the config file self.callbacks = {"set_mod_tap_tempo": self.set_mod_tap_tempo, @@ -170,15 +173,12 @@ def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager - if self.ws_bridge is not None: - self.ws_bridge.stop() def cleanup(self): if self.lcd is not None: self.lcd.cleanup() - if self.ws_bridge is not None: - self.ws_bridge.stop() - logging.info("WebSocket bridge stopped") + self.ws_bridge.stop() + logging.info("WebSocket bridge stopped") # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets @@ -480,7 +480,13 @@ def poll_system_info(self): def _handle_ws_message(self, msg: WebSocketMessage): """Handle incoming WebSocket message from MOD-UI""" - if isinstance(msg, LoadingEndMessage): + if isinstance(msg, LoadingStartMessage): + self._is_pedalboard_loading = True + cleared = self.ws_bridge.clear_queue() + if cleared: + logging.debug(f"Cleared {cleared} stale outbound messages on loading_start") + + elif isinstance(msg, LoadingEndMessage): logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") self.next_pedalboard_preset_index = msg.snapshot_id @@ -544,6 +550,7 @@ def poll_modui_changes(self): # Check for pedalboard change via last.json if self.last_json_monitor.check_for_change(): + self._is_pedalboard_loading = True self.lcd.draw_info_message("Loading...") mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and mod_bundle != self.current.pedalboard.bundle: @@ -634,6 +641,9 @@ def set_current_pedalboard(self, pedalboard): self.load_current_presets() self.update_lcd() + # Resume outbound WebSocket messages now that the new pedalboard is fully set up. + self._is_pedalboard_loading = False + # Prepare blend modes if configured (snapshot-based activation) try: blend_configs = cfg.get('blend_snapshots', []) if cfg else [] @@ -901,7 +911,8 @@ def toggle_plugin_bypass(self): return # Non-footswitch plugin: emit only; the inbound echo updates state and LCD. target_bypass = not inst.is_bypassed() - self.ws_bridge.send_parameter(inst.instance_id, ":bypass", 1.0 if target_bypass else 0.0) + if not self._is_pedalboard_loading: + self.ws_bridge.send_parameter(inst.instance_id, ":bypass", 1.0 if target_bypass else 0.0) self.lcd.draw_plugin_select(inst) # selection highlight (navigation, not bypass) # @@ -1356,7 +1367,8 @@ def parameter_value_change(self, direction, commit_callback): def parameter_value_commit(self): param = self.deep.selected_parameter - self.ws_bridge.send_parameter(param.instance_id, param.symbol, param.value) + if not self._is_pedalboard_loading: + self.ws_bridge.send_parameter(param.instance_id, param.symbol, param.value) # # LCD Stuff diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 510f89ebb..0081a796c 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -37,7 +37,7 @@ import pistomp.settings as Settings from blend.snapshot import SnapshotManager from modalapi.websocket_bridge import AsyncWebSocketBridge -from modalapi.ws_protocol import parse_message, LoadingEndMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, AddPluginMessage, ParamSetMessage, MidiMapMessage, WebSocketMessage +from modalapi.ws_protocol import parse_message, LoadingEndMessage, LoadingStartMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, AddPluginMessage, ParamSetMessage, MidiMapMessage, WebSocketMessage from modalapi.pedalboard_monitor import FileChangeMonitor, read_pedalboard_bundle from pistomp.footswitch import Footswitch @@ -107,6 +107,9 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") self.ws_bridge.start() logging.info("WebSocket bridge started") + # Suppress outbound WebSocket messages while a pedalboard change is in flight. + self._is_pedalboard_loading = False + # Tuner state self._tuner_engine: TunerEngine | None = None self._tuner_panel: TunerPanel | None = None @@ -131,8 +134,6 @@ def __del__(self): logging.info("Handler cleanup") if self.wifi_manager: del self.wifi_manager - # ws_bridge.stop() lives in cleanup(), not here — join() in __del__ blows up - # during interpreter shutdown on Py 3.14. Daemon thread dies with the process. def cleanup(self): if self._tuner_engine is not None: @@ -145,9 +146,8 @@ def cleanup(self): self._lcd.cleanup() if self._hardware is not None: self._hardware.cleanup() - if self.ws_bridge is not None: - self.ws_bridge.stop() - logging.info("WebSocket bridge stopped") + self.ws_bridge.stop() + logging.info("WebSocket bridge stopped") # Container for dynamic data which is unique to the "current" pedalboard # The self.current pointed above will point to this object which gets @@ -337,7 +337,13 @@ def _handle_blend_mode_snapshot_change(self, new_snapshot_index: int): def _handle_ws_message(self, msg: WebSocketMessage): """Handle incoming WebSocket message from MOD-UI.""" - if isinstance(msg, LoadingEndMessage): + if isinstance(msg, LoadingStartMessage): + self._is_pedalboard_loading = True + cleared = self.ws_bridge.clear_queue() + if cleared: + logging.debug(f"Cleared {cleared} stale outbound messages on loading_start") + + elif isinstance(msg, LoadingEndMessage): logging.debug(f"WebSocket: Pedalboard loading finished, snapshot={msg.snapshot_id}") # Sometimes mod-ui sends us -1 for preset index, but shows 0 anyway ("Default") self.next_pedalboard_preset_index = max(0, msg.snapshot_id) @@ -421,6 +427,7 @@ def poll_modui_changes(self): # Check for pedalboard change via last.json if self.last_json_monitor.check_for_change(): + self._is_pedalboard_loading = True self.lcd.draw_info_message("Loading...") mod_bundle = read_pedalboard_bundle(self.last_json_monitor.path) if mod_bundle and self.current and mod_bundle != self.current.pedalboard.bundle: @@ -609,6 +616,9 @@ def set_current_pedalboard(self, pedalboard): self.blend_modes = {} self.active_blend_mode = None + # Resume outbound WebSocket messages now that the new pedalboard is fully set up. + self._is_pedalboard_loading = False + def bind_current_pedalboard(self): # "current" being the pedalboard mod-host says is current # The pedalboard data has already been loaded, but this will overlay @@ -770,7 +780,8 @@ def toggle_plugin_bypass(self, widget, plugin): # No echo arrives for WS-initiated bypass. Contrast with footswitches, # which send MIDI CC → mod-host internally → feedback → msg_callback. value = plugin.toggle_bypass() - self.ws_bridge.send_parameter(plugin.instance_id, ":bypass", value) + if not self._is_pedalboard_loading: + self.ws_bridge.send_parameter(plugin.instance_id, ":bypass", value) self.lcd.toggle_plugin(widget, plugin) def update_lcd_fs(self, footswitch=None, bypass_change=False): @@ -790,7 +801,8 @@ def parameter_value_commit(self, param, value): self.audio_parameter_commit(param.symbol, value) return - self.ws_bridge.send_parameter(param.instance_id, param.symbol, param.value) + if not self._is_pedalboard_loading: + self.ws_bridge.send_parameter(param.instance_id, param.symbol, param.value) def parameter_midi_change(self, param, direction): if param: diff --git a/tests/v1/test_plugins.py b/tests/v1/test_plugins.py index 26de73c56..9465a8caf 100644 --- a/tests/v1/test_plugins.py +++ b/tests/v1/test_plugins.py @@ -1,3 +1,4 @@ +# pyright: reportAttributeAccessIssue=false """Basic v1/v2 (mod.py) coverage for source-of-truth bypass. Drives Mod.toggle_plugin_bypass directly with a hand-wired handler (no hardware, @@ -17,6 +18,7 @@ def _make_handler(selected_plugin): handler.ws_bridge = FakeWebSocketBridge() handler.lcd = MagicMock() handler.get_selected_instance = lambda: selected_plugin + handler._is_pedalboard_loading = False return handler @@ -27,6 +29,7 @@ def _make_drain_handler(plugins): handler.ws_bridge = FakeWebSocketBridge() handler.lcd = MagicMock() handler.current = SimpleNamespace(pedalboard=SimpleNamespace(plugins=plugins)) + handler._is_pedalboard_loading = False return handler @@ -61,3 +64,27 @@ def test_v1_add_dump_reseeds_bypass_on_reconnect(make_plugin): handler.poll_ws_messages() assert plugin.is_bypassed() + + +def test_v1_outbound_ws_suppressed_during_pedalboard_change(make_plugin): + """While a pedalboard change is in flight, outbound param_set messages are dropped.""" + plugin = make_plugin("fuzz", bypassed=False, has_footswitch=False) + handler = _make_handler(plugin) + handler.current = SimpleNamespace(pedalboard=SimpleNamespace(plugins=[plugin])) + handler._is_pedalboard_loading = True + + handler.toggle_plugin_bypass() + + # mod.py uses emit-only semantics (state unchanged until echo arrives). + # The key assertion is that NO ws message was sent while suppressed. + assert not plugin.is_bypassed() + assert handler.ws_bridge.sent_values_for("fuzz", ":bypass") == [] + + +def test_v1_loading_start_suppresses_outbound_ws(): + """Receiving loading_start from MOD-UI sets the suppression flag.""" + handler = _make_drain_handler([]) + assert not getattr(handler, "_is_pedalboard_loading", False) + handler.ws_bridge.inject("loading_start 0") + handler.poll_ws_messages() + assert handler._is_pedalboard_loading is True diff --git a/tests/v3/test_pedalboards.py b/tests/v3/test_pedalboards.py index ff6f1bdde..7d80669d5 100644 --- a/tests/v3/test_pedalboards.py +++ b/tests/v3/test_pedalboards.py @@ -84,3 +84,58 @@ def get_side_effect(url, **kwargs): assert handler.current assert handler.current.pedalboard.title == "New Rig" snapshot() + + +def test_v3_outbound_ws_suppressed_during_pedalboard_change(v3_system: SystemFixture, make_plugin): + """While a pedalboard change is in flight, outbound param_set messages are dropped.""" + handler = v3_system.handler + ws_bridge = v3_system.ws_bridge + + # Start with a loaded pedalboard + old_plugin = make_plugin("old_fuzz", category="Distortion", bypassed=False) + assert handler.current is not None + handler.current.pedalboard.plugins = [old_plugin] + handler.lcd.link_data(handler.pedalboard_list, handler.current, handler.hardware.footswitches) + handler.lcd.draw_main_panel() + widget = next(w for w in handler.lcd.w_plugins if w.object is old_plugin) + ws_bridge.sent.clear() + + # Simulate a user tapping the bypass on the old pedalboard while a change is in flight + handler._is_pedalboard_loading = True + handler.toggle_plugin_bypass(widget, old_plugin) + + # The bypass should flip locally, but NO ws message should be sent + assert old_plugin.is_bypassed() + assert ws_bridge.sent_values_for("old_fuzz", ":bypass") == [] + + # After clearing suppression, sends resume + handler._is_pedalboard_loading = False + handler.toggle_plugin_bypass(widget, old_plugin) + assert not old_plugin.is_bypassed() + assert ws_bridge.sent_values_for("old_fuzz", ":bypass") == [0.0] + + +def test_v3_loading_start_suppresses_outbound_ws(v3_system: SystemFixture): + """Receiving loading_start from MOD-UI sets the suppression flag.""" + handler = v3_system.handler + ws_bridge = v3_system.ws_bridge + + assert not getattr(handler, "_is_pedalboard_loading", False) + ws_bridge.inject("loading_start 0") + handler.poll_ws_messages() + assert handler._is_pedalboard_loading is True + + +def test_v3_set_current_pedalboard_clears_suppression(v3_system: SystemFixture, make_plugin): + """After set_current_pedalboard completes, suppression is cleared so normal operation resumes.""" + handler = v3_system.handler + handler._is_pedalboard_loading = True + assert handler.current is not None + + pb = handler.current.pedalboard + new_plugin = make_plugin("new_fuzz", category="Distortion", bypassed=False) + pb.plugins = [new_plugin] + + handler.set_current_pedalboard(pb) + + assert handler._is_pedalboard_loading is False