From 7eb7ea2ebbd9fbef6b80529a31ea881f4a8630cc Mon Sep 17 00:00:00 2001 From: John-PS Date: Sat, 15 Aug 2026 01:18:00 -0400 Subject: [PATCH] fix(modhandler): reconcile local sinks on successful CC and ALSA emission Parameters published through local sinks (ALSA audio and MIDI CC) receive no remote echo from MOD-UI. Without explicit reconciliation upon successful send, param._confirmed was never updated, causing parameter edits in Parameterdialog to bounce back to initial boot values on subsequent events. - Call param.reconcile(param.value) in _publish_audio and _publish_cc - Add regression tests in tests/v3/test_parameter_edit_rollback.py - Update CHANGELOG.md --- CHANGELOG.md | 1 + modalapi/modhandler.py | 2 + tests/v3/test_parameter_edit_rollback.py | 92 ++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 tests/v3/test_parameter_edit_rollback.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af9ef9d6..952d03ba7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - Global EQ now accounts for the current sample rate ### Fixed +- Fix parameter rollback on MIDI CC and ALSA local sinks by reconciling confirmed state upon successful emission - WiFi: fixed WPA3 support - Fix footswitch ↔ plugin param binding updates in menus - Fix inverted Q factor in x42 EQ panel diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 979ba0bac..f6ed9ad89 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -1237,11 +1237,13 @@ def _publish_bpm(self, param: Parameter) -> bool: def _publish_audio(self, param: Parameter) -> bool: """ A local ALSA write. No remote echo, so the send always lands.""" self.audio_parameter_commit(param.symbol, param.value) + param.reconcile(param.value) return True def _publish_cc(self, controller: EncoderController, param: Parameter) -> bool: """Publish the parameter (to MOD-UI or anything else) via MIDI CC.""" self._emit_midi(controller, controller.to_midi(param.value)) + param.reconcile(param.value) return True def _publish_plugin_param(self, param: Parameter) -> bool: diff --git a/tests/v3/test_parameter_edit_rollback.py b/tests/v3/test_parameter_edit_rollback.py new file mode 100644 index 000000000..93a56c8bd --- /dev/null +++ b/tests/v3/test_parameter_edit_rollback.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import pytest +from common.parameter import Parameter, PortInfo, Symbol +from modalapi.plugin import Plugin +from tests.types import SystemFixture +from uilib.parameterdialog import Parameterdialog + + +def _make_plugin(make_plugin, instance_id="fuzz", bypassed=False, binding=None) -> Plugin: + gain_info: PortInfo = {"shortName": "Gain", "symbol": "gain", "ranges": {"minimum": 0.0, "maximum": 1.0}} + gain_param = Parameter(gain_info, 0.5, binding, instance_id) + plugin = make_plugin(instance_id, bypassed=bypassed, parameters={Symbol("gain"): gain_param}) + return plugin + + +def _install(v3_system: SystemFixture, make_plugin, instance_id="fuzz", binding=None) -> Plugin: + handler = v3_system.handler + hw = v3_system.hw + assert handler.current + plugin = _make_plugin(make_plugin, instance_id, bypassed=False, binding=binding) + handler.current.pedalboard.plugins = [plugin] + handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + handler.lcd.draw_main_panel() + return plugin + + +def test_parameterdialog_nav_turn_updates_value_and_sends_ws(v3_system: SystemFixture, make_plugin): + """When a parameter is edited in Parameterdialog with NAV encoder, it updates value and queues param_set.""" + plugin = _install(v3_system, make_plugin) + param = plugin.parameters[Symbol("gain")] + dialog = v3_system.handler.lcd.draw_parameter_dialog(param) + assert isinstance(dialog, Parameterdialog) + assert param.value == 0.5 + + # Turn NAV forward + dialog.input_step(1, 1) + new_val = param.value + assert new_val > 0.5 + + # Outbound queue should have param_set + assert len(v3_system.ws_bridge.sent) > 0 + msg = v3_system.ws_bridge.sent[-1] + assert msg.startswith("param_set /graph/fuzz/gain") + + +def test_parameterdialog_rollback_when_loading_is_active(v3_system: SystemFixture, make_plugin): + """If _is_pedalboard_loading is True, sink returns False and parameter reverts to confirmed.""" + plugin = _install(v3_system, make_plugin) + param = plugin.parameters[Symbol("gain")] + dialog = v3_system.handler.lcd.draw_parameter_dialog(param) + assert isinstance(dialog, Parameterdialog) + assert param.value == 0.5 + + # Simulate loading state stuck True + v3_system.handler._is_pedalboard_loading = True + + dialog.input_step(1, 1) + + # Because loading is active, commit rolls back to 0.5 + assert param.value == 0.5 + assert dialog.last_param_value == 0.5 + + +def test_midi_cc_bound_param_confirms_on_cc_emit(v3_system: SystemFixture, make_plugin): + """For a MIDI CC-bound parameter, _publish_cc emits CC. Because mod-host emits no echo + for CC, a successful CC emit MUST confirm the value so it does not stay unconfirmed.""" + hw = v3_system.hw + enc1 = next(e for e in hw.encoders if e.id == 1) + binding = f"{enc1.midi_channel}:{enc1.midi_CC}" + hw.controllers[binding] = enc1 + + plugin = _install(v3_system, make_plugin, binding=binding) + param = plugin.parameters[Symbol("gain")] + enc1.bind_to_parameter(param) + dialog = v3_system.handler.lcd.draw_parameter_dialog(param) + assert isinstance(dialog, Parameterdialog) + + # Initial state + assert param.value == 0.5 + assert param._confirmed == 0.5 + + # Turn encoder in dialog + dialog.input_step(1, 1) + new_val = param.value + assert new_val > 0.5 + + # Verify MIDI CC was emitted + hw.midiout.send_message.assert_called() + + # The confirmed value should be updated because CC emit has no remote echo + assert param._confirmed == pytest.approx(new_val, abs=1e-4)