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
30 changes: 21 additions & 9 deletions modalapi/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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)

#
Expand Down Expand Up @@ -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
Expand Down
30 changes: 21 additions & 9 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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:
Expand Down
27 changes: 27 additions & 0 deletions tests/v1/test_plugins.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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


Expand All @@ -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


Expand Down Expand Up @@ -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
55 changes: 55 additions & 0 deletions tests/v3/test_pedalboards.py
Original file line number Diff line number Diff line change
Expand Up @@ -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