From 7b4dca2b64382db1e52232da6f25398e421cc854 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 01:24:26 -0400 Subject: [PATCH 1/6] New longpress actions --- GUIDE.md | 10 +- common/contexts.py | 12 ++ modalapi/modhandler.py | 71 ++++++++++ pistomp/config.py | 34 ++++- pistomp/controller_manager.py | 38 ++++- pistomp/footswitch.py | 14 ++ pistomp/hardware.py | 5 +- pistomp/lcd320x240.py | 15 +- tests/test_config_schema.py | 39 ++++++ tests/v3/test_longpress_actions.py | 213 +++++++++++++++++++++++++++++ 10 files changed, 429 insertions(+), 22 deletions(-) create mode 100644 tests/v3/test_longpress_actions.py diff --git a/GUIDE.md b/GUIDE.md index 274cecc0d..e02459523 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -92,10 +92,12 @@ uv-managed venv. Don't try to pip-install the system ones. and connect dumps rebroadcast unconditionally. Reselecting a *board* is a full resync. Reselecting a *snapshot* is not. -- **A UI bypass gets no echo.** mod-ui skips the origin socket, and mod-host emits no - `param_set` for bypasses it received from mod-ui. Footswitch bypasses *do* echo - (they arrive as MIDI). So the UI path must update local state itself — this - asymmetry is deliberate, not a bug to "fix." +- **A UI bypass of a footswitch-less plugin gets no echo.** mod-ui skips the origin + socket, and mod-host emits no `param_set` for bypasses it received from mod-ui. So + that path must update local state itself (`toggle_plugin_bypass`'s optimistic write). + A footswitch-bound plugin is the opposite: `toggle_plugin_bypass` routes through the + footswitch press path, which sends MIDI CC → mod-host → feedback echo, and that echo + reconciles it. The asymmetry is deliberate, not a bug to "fix." - **Never extract `lv2plugins.tar.gz` whole.** It's huge. Pull single files with `tar --to-stdout`. Prefer inspecting the live device anyway. diff --git a/common/contexts.py b/common/contexts.py index 89f9f20b1..935fd1a56 100644 --- a/common/contexts.py +++ b/common/contexts.py @@ -127,6 +127,18 @@ class PresetEffect(Effect): direction: str # "UP" | "DOWN" | "" +@dataclass(frozen=True) +class PedalboardEffect(Effect): + direction: str # "UP" | "DOWN" — next/prev pedalboard within the current bank + + +@dataclass(frozen=True) +class RawMidiCcEffect(Effect): + # No owning controller; mod-ui's echo reconciles a MIDI-learned plugin. + channel: int # 0-15 + cc: int + + @dataclass(frozen=True) class TapTempoEffect(Effect): pass diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 092d9d3c4..1bb63c52d 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -28,6 +28,7 @@ import subprocess import sys import yaml +from collections import namedtuple from collections.abc import Callable import functools from functools import cached_property @@ -48,7 +49,9 @@ EventKind, MidiCcEffect, ParamEffect, + PedalboardEffect, PresetEffect, + RawMidiCcEffect, RelayEffect, TapTempoEffect, ) @@ -122,6 +125,12 @@ def _remove_binding_row(layer: ContextLayer, binding_id: str) -> None: layer.rows[(cls, event_kind)] = [d for d in rows if d.control.id != binding_id] +class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])): + """(channel, cc) identity for a raw-CC longpress row. Echo from mod-ui + reconciles the learned plugin; this key tracks "what to send next" between + presses. Drift self-corrects on every longpress (see PLAN.md §1).""" + + class Modhandler(Handler): __single = None @@ -222,6 +231,8 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") "toggle_bypass": self.system_toggle_bypass, "toggle_tap_tempo_enable": self.toggle_tap_tempo_enable, "toggle_tuner_enable": self.toggle_tuner_enable, + "next_pedalboard": self.next_pedalboard, + "previous_pedalboard": self.previous_pedalboard, } # External MIDI device synchronization @@ -235,6 +246,12 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") # Footswitch longpress/chord resolver (rebuilt on pedalboard change) self.chord_helper = FootswitchChords() + # Toggle direction for raw-CC longpress rows (Feature 1). Each (channel, + # cc) tracks "next value to send"; first press is always 127 (on). The + # WS echo from mod-ui keeps the plugin tile/badge correct; the footswitch + # LED stays in its PRESS role by design. See PLAN.md §1. + self._longpress_cc_state: dict[LongpressCcKey, bool] = {} + def cleanup(self): if self._tuner_muted: self.audiocard.set_output_muted(False) @@ -512,8 +529,22 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool: fs.toggle_relays(new_toggled) fs.set_led(new_toggled) self.update_lcd_fs(bypass_change=True) + case RawMidiCcEffect(channel=ch, cc=cc): + key = LongpressCcKey(channel=ch, cc=cc) + on = self._longpress_cc_state[key] = not self._longpress_cc_state.get(key, False) + self._emit_raw_cc(ch, cc, 127 if on else 0) + case PedalboardEffect(direction=direction): + if direction == "DOWN": + self.previous_pedalboard() + else: + self.next_pedalboard() return decl.consume + def _emit_raw_cc(self, channel: int, cc: int, value: int) -> None: + """Send a CC with no owning controller (Feature 1 longpress). Bypasses + _emit_midi's controller.midi_CC guard; routes to virtual out only.""" + self.hardware.midiout.send_message([channel | CONTROL_CHANGE, cc, int(value)]) + def _emit_midi(self, controller, midi_value: int) -> None: """Send a CC. Tries the external port if routed; falls back to virtual.""" if controller.midi_CC is None: @@ -1149,6 +1180,46 @@ def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None: if resp2 is None or resp2.status_code != 200: logging.error("Bad Rest request: %s %s" % (uri, data)) + def pedalboards_in_bank(self) -> list[Pedalboard.Pedalboard] | None: + """Ordered Pedalboards for the current bank, or None if no bank is set + (caller falls back to pedalboard_list). Same O(N²) title→bundle lookup + the pedalboard menu does.""" + bank_pbs = self.banks.get(self.current_bank) if self.current_bank else None + if bank_pbs is None: + return None + result = [] + for title in bank_pbs: + for p in self.pedalboard_list: + if p.title == title: + result.append(p) + break + return result + + def _next_pedalboard_index(self, incr: bool) -> int | None: + pbs = self.pedalboards_in_bank() + if pbs is None: + pbs = self.pedalboard_list + if not pbs: + return None + current = self.current.pedalboard + try: + idx = next(i for i, p in enumerate(pbs) if p.bundle == current.bundle) + except StopIteration: + return 0 if incr else len(pbs) - 1 + return (idx + 1) % len(pbs) if incr else (idx - 1) % len(pbs) + + def _pedalboard_nav(self, incr: bool) -> None: + pbs = self.pedalboards_in_bank() or self.pedalboard_list + idx = self._next_pedalboard_index(incr) + if idx is not None: + self.pedalboard_change(pbs[idx]) + + def next_pedalboard(self) -> None: + self._pedalboard_nav(True) + + def previous_pedalboard(self) -> None: + self._pedalboard_nav(False) + # # Preset Stuff # diff --git a/pistomp/config.py b/pistomp/config.py index c9eaee1a3..698cfbb61 100644 --- a/pistomp/config.py +++ b/pistomp/config.py @@ -83,11 +83,35 @@ "type": "integer" }, "longpress": { - "type" : ["array", "string"], - "items" : { - "type" : "string", - "enum" : ["next_snapshot", "previous_snapshot", "toggle_bypass", "set_mod_tap_tempo", "toggle_tap_tempo_enable"] - } + "oneOf": [ + { + "type": "string", + "enum": ["next_snapshot", "previous_snapshot", "toggle_bypass", "set_mod_tap_tempo", "toggle_tap_tempo_enable", "toggle_tuner_enable"] + }, + { + "type": "array", + "items": { + "type": "string", + "enum": ["next_snapshot", "previous_snapshot", "toggle_bypass", "set_mod_tap_tempo", "toggle_tap_tempo_enable", "toggle_tuner_enable"] + } + }, + { + "type": "object", + "additionalProperties": False, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "midi_CC": {"type": "integer", "minimum": 0, "maximum": 127}, + "preset": { + "oneOf": [ + {"type": "integer"}, + {"type": "string", "enum": ["UP", "DOWN"]} + ] + }, + "pedalboard": {"type": "string", "enum": ["UP", "DOWN"]} + } + } + ] }, "midi_CC": { "type": "integer" diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index f5167477f..a33483506 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -28,10 +28,13 @@ ContextStack, ControlClass, ControlRef, + Effect, EventKind, MidiCcEffect, ParamEffect, + PedalboardEffect, PresetEffect, + RawMidiCcEffect, RelayEffect, ShadowState, TapTempoEffect, @@ -254,7 +257,14 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: Relay longpress is independent of the short-press action: a relay footswitch has both a PRESS row (CC toggle or plugin :bypass) and a LONGPRESS row (RelayEffect). It's added for any footswitch with a - relay_list, regardless of its PRESS binding.""" + relay_list, regardless of its PRESS binding. + + Mapping-form longpress (the new config spelling — `longpress: {midi_CC: + 64}` etc., exclusive with the chord string/list form) rows a single + LONGPRESS decl: a RawMidiCcEffect, PresetEffect, or PedalboardEffect. + Co-declaring a relay_list with a mapping form is an odd config; the + relay row is added first so the resolver keeps its precedent if both + happen to be present.""" for fs in self._hw.footswitches: if self._hw.is_external(fs): continue # owned by _bind_external_controllers @@ -274,6 +284,18 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: ) ) + # Mapping-form longpress: dict config, parsed by Footswitch. + lp = fs.longpress_action + if lp is not None: + pedalboard_layer.add( + BindingDecl( + control=ControlRef(cls=ControlClass.FOOTSWITCH, id=key), + event_kind=EventKind.LONGPRESS, + effects=self._longpress_action_effects(lp, fs), + context=pedalboard_layer.ref, + ) + ) + if fs.parameter is not None: continue # plugin :bypass — _bind_plugin_parameters rowed it @@ -319,3 +341,17 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: context=pedalboard_layer.ref, ) ) + + @staticmethod + def _longpress_action_effects(lp: dict, fs: Footswitch) -> tuple[Effect, ...]: + """Translate a mapping-form longpress dict into a single-effect tuple. + Schema guarantees one key; we take the first present form to keep the + shape closed (config is mutually exclusive — see config.py).""" + if "midi_CC" in lp: + return (RawMidiCcEffect(channel=fs.midi_channel, cc=int(lp["midi_CC"])),) + if "preset" in lp: + return (PresetEffect(direction=str(lp["preset"])),) + if "pedalboard" in lp: + return (PedalboardEffect(direction=str(lp["pedalboard"])),) + # Schema rejects; defensively empty rather than raise. + return () diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index 892156bf8..bdfc45d7b 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -43,6 +43,10 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.category = None self.pixel = pixel self.longpress_groups = [] + # Mapping-form longpress dict ({midi_CC|preset|pedalboard: ...}). When + # set, _bind_footswitch_actions rows a LONGPRESS decl for it; exclusive + # with the chord form. + self.longpress_action: dict | None = None self.disabled = False self.taptempo = taptempo @@ -159,10 +163,18 @@ def set_lcd_color(self, color): def set_longpress_groups(self, groups): if groups is None: self.longpress_groups = [] + self.longpress_action = None elif isinstance(groups, str): self.longpress_groups = groups.split() + self.longpress_action = None elif isinstance(groups, list): self.longpress_groups = groups + self.longpress_action = None + elif isinstance(groups, dict): + # Mapping form: one of {midi_CC: int}, {preset: UP|DOWN|}, + # {pedalboard: UP|DOWN}. Exclusive with the chord form. + self.longpress_groups = [] + self.longpress_action = groups def poll(self): if self.disabled: @@ -203,4 +215,6 @@ def clear_pedalboard_info(self): self.preset_direction = None self.preset_callback_arg = None self.parameter = None + self.longpress_groups = [] + self.longpress_action = None self.clear_relays() diff --git a/pistomp/hardware.py b/pistomp/hardware.py index eedb159f1..16cb4f275 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -496,7 +496,10 @@ def __init_footswitches(self, cfg): key = format("%d:%d" % (self.midi_channel, fs.midi_CC)) self.controllers[key] = fs # TODO problem if this creates a new element? - # Preset Control + # Preset Control. midi_CC is cleared: dispatch_key falls back to + # "fs:" so the PRESS/LONGPRESS rows still resolve, and + # dropping it out of hw.controllers keeps an unrelated plugin's + # MIDI-learned :bypass from binding onto this footswitch. if Token.PRESET in f: self.__clear_footswitch_midi_cc(fs) preset_value = f[Token.PRESET] diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 3edda9f08..f605c3cbf 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -540,23 +540,16 @@ def draw_preset(self, preset_name): def draw_pedalboard_menu(self, event, widget): items = [] - bank_pbs = util.DICT_GET(self.handler.get_banks(), self.handler.get_bank()) def pedalboard_change(pb): assert self.handler self._is_pedalboard_load = True self.handler.pedalboard_change(pb) - if bank_pbs is None: - # No bank so display all pedalboards as they're stored (alphabetically) - for p in self.pedalboards: - items.append((p.title, pedalboard_change, p)) - else: - # Bank is set so show only those in the bank and in the order defined by the bank - for b in bank_pbs: - for p in self.pedalboards: # LAME ugly O(N2) search - if p.title == b: - items.append((p.title, pedalboard_change, p)) + # None → no bank; show all pedalboards as stored (alphabetically). + pbs = self.handler.pedalboards_in_bank() or self.pedalboards + for p in pbs: + items.append((p.title, pedalboard_change, p)) self.draw_selection_menu( items, "Pedalboards", auto_dismiss=True, dismiss_option=True, default_item=self.current.pedalboard.title diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index 819b4a0f9..40b686df8 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -99,3 +99,42 @@ def test_midi_channel_not_required_without_midi_port(section, entry): } } validate(instance=cfg, schema=schema) + + +# --------------------------------------------------------------------------- +# Mapping-form longpress (PLAN.md) +# --------------------------------------------------------------------------- + + +def _fs_cfg(longpress): + return { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "midi_CC": 60, "longpress": longpress}], + } + } + + +@pytest.mark.parametrize("longpress", [ + {"midi_CC": 64}, + {"preset": "UP"}, + {"preset": 2}, + {"pedalboard": "DOWN"}, + "next_snapshot", + ["next_snapshot", "toggle_bypass"], + "toggle_tuner_enable", +]) +def test_longpress_mapping_form_valid(longpress): + validate(instance=_fs_cfg(longpress), schema=schema) + + +@pytest.mark.parametrize("longpress", [ + {"midi_CC": "foo"}, + {"midi_CC": 64, "preset": "UP"}, # mutually exclusive + {"pedalboard": "SIDEWAYS"}, + {"bogus": 1}, +]) +def test_longpress_mapping_form_invalid(longpress): + with pytest.raises(exceptions.ValidationError): + validate(instance=_fs_cfg(longpress), schema=schema) diff --git a/tests/v3/test_longpress_actions.py b/tests/v3/test_longpress_actions.py new file mode 100644 index 000000000..20f7dfeb5 --- /dev/null +++ b/tests/v3/test_longpress_actions.py @@ -0,0 +1,213 @@ +"""Mapping-form `longpress:` actions (PLAN.md): raw-CC toggle, specific/next +snapshot, and prev/next pedalboard within a bank. + +The mapping form (`longpress: {midi_CC: 64}`, `{preset: 2}`, `{pedalboard: UP}`) +rows a single LONGPRESS decl alongside whatever PRESS action the footswitch +already carries. It's exclusive with the chord string/list form. +""" + +from rtmidi.midiconstants import CONTROL_CHANGE + +from common.contexts import ( + ControlClass, + EventKind, + PedalboardEffect, + PresetEffect, + RawMidiCcEffect, +) +from pistomp.footswitch import Footswitch +from pistomp.input.event import SwitchEvent, SwitchEventKind +from tests.types import SystemFixture + + +def _fs_key(fs: Footswitch) -> str: + if fs.midi_CC is not None: + return f"{fs.midi_channel}:{fs.midi_CC}" + return f"fs:{fs.id}" + + +def _longpress_rows(handler, key): + rows = handler._controller_manager.effective_table.layers[0].rows.get( + (ControlClass.FOOTSWITCH, EventKind.LONGPRESS), [] + ) + return [r for r in rows if r.control.id == key] + + +# --------------------------------------------------------------------------- +# Feature 1: raw-CC longpress +# --------------------------------------------------------------------------- + + +def test_longpress_raw_midi_cc(v3_system: SystemFixture): + handler = v3_system.handler + fs = v3_system.hw.footswitches[0] + fs.add_preset(direction="UP") + fs.longpress_action = {"midi_CC": 64} + handler.bind_current_pedalboard() + + rows = _longpress_rows(handler, _fs_key(fs)) + assert len(rows) == 1 + effs = [e for e in rows[0].effects if isinstance(e, RawMidiCcEffect)] + assert len(effs) == 1 and effs[0].cc == 64 and effs[0].channel == fs.midi_channel + + fs.toggled = False + v3_system.hw.midiout.send_message.reset_mock() + event = SwitchEvent(controller=fs, kind=SwitchEventKind.LONGPRESS, timestamp=1.0) + + handler.handle(event) + v3_system.hw.midiout.send_message.assert_called_with([fs.midi_channel | CONTROL_CHANGE, 64, 127]) + assert fs.toggled is False # LED stays in its PRESS role + + handler.handle(event) + v3_system.hw.midiout.send_message.assert_called_with([fs.midi_channel | CONTROL_CHANGE, 64, 0]) + + +def test_longpress_raw_midi_cc_drift_self_corrects(v3_system: SystemFixture): + handler = v3_system.handler + fs = v3_system.hw.footswitches[0] + fs.longpress_action = {"midi_CC": 64} + handler.bind_current_pedalboard() + + event = SwitchEvent(controller=fs, kind=SwitchEventKind.LONGPRESS, timestamp=1.0) + sends = v3_system.hw.midiout.send_message + + sends.reset_mock() + handler.handle(event) # 127 + handler.handle(event) # 0 + assert sends.call_args_list[0][0][0][2] == 127 + assert sends.call_args_list[1][0][0][2] == 0 + + # A web-UI toggle can't touch our flywheel dict; the next press resumes on. + handler.handle(event) + assert sends.call_args_list[2][0][0][2] == 127 + + +# --------------------------------------------------------------------------- +# Feature 2: longpress snapshot +# --------------------------------------------------------------------------- + + +def test_longpress_snapshot_specific_index(v3_system: SystemFixture, monkeypatch): + handler = v3_system.handler + fs = v3_system.hw.footswitches[0] + fs.longpress_action = {"preset": 2} + handler.bind_current_pedalboard() + + rows = _longpress_rows(handler, _fs_key(fs)) + assert any(isinstance(e, PresetEffect) and e.direction == "2" for e in rows[0].effects) + + called: list[int] = [] + monkeypatch.setattr(handler, "preset_set_and_change", lambda i: called.append(i)) + handler.handle(SwitchEvent(controller=fs, kind=SwitchEventKind.LONGPRESS, timestamp=1.0)) + assert called == [2] + + +def test_longpress_snapshot_up_down(v3_system: SystemFixture): + handler = v3_system.handler + hw = v3_system.hw + + fs_up = hw.footswitches[0] + fs_up.longpress_action = {"preset": "UP"} + fs_dn = hw.footswitches[1] + fs_dn.longpress_action = {"preset": "DOWN"} + handler.bind_current_pedalboard() + + calls: list[str] = [] + handler.preset_incr_and_change = lambda *a: calls.append("incr") + handler.preset_decr_and_change = lambda *a: calls.append("decr") + + handler.handle(SwitchEvent(controller=fs_up, kind=SwitchEventKind.LONGPRESS, timestamp=1.0)) + handler.handle(SwitchEvent(controller=fs_dn, kind=SwitchEventKind.LONGPRESS, timestamp=2.0)) + assert calls == ["incr", "decr"] + + +# --------------------------------------------------------------------------- +# Feature 3: pedalboard scroll +# --------------------------------------------------------------------------- + + +def _spy_pedalboard_change(handler): + changed: list = [] + handler.pedalboard_change = lambda pb: changed.append(pb) + return changed + + +def test_pedalboard_incr_in_bank(v3_system: SystemFixture): + handler = v3_system.handler + pbs = handler.pedalboard_list + assert len(pbs) >= 2 + handler.current_bank = "MyBank" + handler.banks = {"MyBank": [p.title for p in pbs]} + handler.set_current_pedalboard(pbs[0]) + + changed = _spy_pedalboard_change(handler) + handler.next_pedalboard() + assert changed == [pbs[1]] + + +def test_pedalboard_wrap_at_bank_end(v3_system: SystemFixture): + handler = v3_system.handler + pbs = handler.pedalboard_list + handler.current_bank = "MyBank" + handler.banks = {"MyBank": [p.title for p in pbs]} + handler.set_current_pedalboard(pbs[-1]) + + changed = _spy_pedalboard_change(handler) + handler.next_pedalboard() + assert changed == [pbs[0]] + + +def test_pedalboard_wrap_at_bank_start(v3_system: SystemFixture): + handler = v3_system.handler + pbs = handler.pedalboard_list + handler.current_bank = "MyBank" + handler.banks = {"MyBank": [p.title for p in pbs]} + handler.set_current_pedalboard(pbs[0]) + + changed = _spy_pedalboard_change(handler) + handler.previous_pedalboard() + assert changed == [pbs[-1]] + + +def test_pedalboard_nav_no_bank(v3_system: SystemFixture): + handler = v3_system.handler + pbs = handler.pedalboard_list + handler.current_bank = None + handler.set_current_pedalboard(pbs[0]) + + changed = _spy_pedalboard_change(handler) + handler.next_pedalboard() + assert changed == [pbs[1]] + + +def test_pedalboard_nav_single_pedalboard_bank(v3_system: SystemFixture): + handler = v3_system.handler + pbs = handler.pedalboard_list + handler.current_bank = "Solo" + handler.banks = {"Solo": [pbs[0].title]} + handler.set_current_pedalboard(pbs[0]) + + changed = _spy_pedalboard_change(handler) + handler.next_pedalboard() + handler.previous_pedalboard() + assert changed == [pbs[0], pbs[0]] + + +# --------------------------------------------------------------------------- +# Chord form still works +# --------------------------------------------------------------------------- + + +def test_longpress_chord_form_still_works(v3_system: SystemFixture): + fs = v3_system.hw.footswitches[0] + fs.set_longpress_groups("next_snapshot") + assert fs.longpress_groups == ["next_snapshot"] + assert fs.longpress_action is None + + fs.set_longpress_groups(["next_snapshot", "toggle_bypass"]) + assert fs.longpress_groups == ["next_snapshot", "toggle_bypass"] + assert fs.longpress_action is None + + fs.set_longpress_groups({"midi_CC": 64}) + assert fs.longpress_groups == [] + assert fs.longpress_action == {"midi_CC": 64} From 1f72c51f33f4c055ec77dd754b9f198f47889599 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 01:33:40 -0400 Subject: [PATCH 2/6] Trim longpress comments Cut verbose comments and drop PLAN.md / "Feature N" references. Co-Authored-By: Claude Opus 4.8 --- modalapi/modhandler.py | 14 +++++--------- pistomp/controller_manager.py | 13 ++++--------- pistomp/footswitch.py | 5 +---- pistomp/hardware.py | 7 +++---- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 1bb63c52d..81c7ea2ef 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -126,9 +126,8 @@ def _remove_binding_row(layer: ContextLayer, binding_id: str) -> None: class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])): - """(channel, cc) identity for a raw-CC longpress row. Echo from mod-ui - reconciles the learned plugin; this key tracks "what to send next" between - presses. Drift self-corrects on every longpress (see PLAN.md §1).""" + """(channel, cc) identity for a raw-CC longpress row; tracks what value to + send next. mod-ui's echo reconciles the learned plugin.""" class Modhandler(Handler): @@ -246,10 +245,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") # Footswitch longpress/chord resolver (rebuilt on pedalboard change) self.chord_helper = FootswitchChords() - # Toggle direction for raw-CC longpress rows (Feature 1). Each (channel, - # cc) tracks "next value to send"; first press is always 127 (on). The - # WS echo from mod-ui keeps the plugin tile/badge correct; the footswitch - # LED stays in its PRESS role by design. See PLAN.md §1. + # First raw-CC longpress sends 127; alternates thereafter. self._longpress_cc_state: dict[LongpressCcKey, bool] = {} def cleanup(self): @@ -541,8 +537,8 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool: return decl.consume def _emit_raw_cc(self, channel: int, cc: int, value: int) -> None: - """Send a CC with no owning controller (Feature 1 longpress). Bypasses - _emit_midi's controller.midi_CC guard; routes to virtual out only.""" + """Send a CC with no owning controller, bypassing _emit_midi's + controller.midi_CC guard; virtual out only.""" self.hardware.midiout.send_message([channel | CONTROL_CHANGE, cc, int(value)]) def _emit_midi(self, controller, midi_value: int) -> None: diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index a33483506..3cb878501 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -259,12 +259,9 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: LONGPRESS row (RelayEffect). It's added for any footswitch with a relay_list, regardless of its PRESS binding. - Mapping-form longpress (the new config spelling — `longpress: {midi_CC: - 64}` etc., exclusive with the chord string/list form) rows a single - LONGPRESS decl: a RawMidiCcEffect, PresetEffect, or PedalboardEffect. - Co-declaring a relay_list with a mapping form is an odd config; the - relay row is added first so the resolver keeps its precedent if both - happen to be present.""" + Mapping-form longpress (`longpress: {midi_CC: 64}` etc., exclusive with + the chord string/list form) rows a single LONGPRESS decl. The relay row + is added first so it keeps precedence if both are present.""" for fs in self._hw.footswitches: if self._hw.is_external(fs): continue # owned by _bind_external_controllers @@ -345,13 +342,11 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: @staticmethod def _longpress_action_effects(lp: dict, fs: Footswitch) -> tuple[Effect, ...]: """Translate a mapping-form longpress dict into a single-effect tuple. - Schema guarantees one key; we take the first present form to keep the - shape closed (config is mutually exclusive — see config.py).""" + The schema guarantees exactly one key.""" if "midi_CC" in lp: return (RawMidiCcEffect(channel=fs.midi_channel, cc=int(lp["midi_CC"])),) if "preset" in lp: return (PresetEffect(direction=str(lp["preset"])),) if "pedalboard" in lp: return (PedalboardEffect(direction=str(lp["pedalboard"])),) - # Schema rejects; defensively empty rather than raise. return () diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index bdfc45d7b..adc43e64e 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -43,8 +43,7 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.category = None self.pixel = pixel self.longpress_groups = [] - # Mapping-form longpress dict ({midi_CC|preset|pedalboard: ...}). When - # set, _bind_footswitch_actions rows a LONGPRESS decl for it; exclusive + # Mapping-form longpress ({midi_CC|preset|pedalboard: ...}); exclusive # with the chord form. self.longpress_action: dict | None = None self.disabled = False @@ -171,8 +170,6 @@ def set_longpress_groups(self, groups): self.longpress_groups = groups self.longpress_action = None elif isinstance(groups, dict): - # Mapping form: one of {midi_CC: int}, {preset: UP|DOWN|}, - # {pedalboard: UP|DOWN}. Exclusive with the chord form. self.longpress_groups = [] self.longpress_action = groups diff --git a/pistomp/hardware.py b/pistomp/hardware.py index 16cb4f275..94db4a077 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -496,10 +496,9 @@ def __init_footswitches(self, cfg): key = format("%d:%d" % (self.midi_channel, fs.midi_CC)) self.controllers[key] = fs # TODO problem if this creates a new element? - # Preset Control. midi_CC is cleared: dispatch_key falls back to - # "fs:" so the PRESS/LONGPRESS rows still resolve, and - # dropping it out of hw.controllers keeps an unrelated plugin's - # MIDI-learned :bypass from binding onto this footswitch. + # Clearing midi_CC drops the fs from hw.controllers, so an + # unrelated plugin's MIDI-learned :bypass can't bind onto it; + # dispatch_key falls back to "fs:" and the rows still resolve. if Token.PRESET in f: self.__clear_footswitch_midi_cc(fs) preset_value = f[Token.PRESET] From 29e6485a8cfa20dbff2954d453aa11f28386f4f0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 01:39:20 -0400 Subject: [PATCH 3/6] Type longpress_action as a TypedDict Replace the bare dict with LongpressActionConfig (TypedDict), cast once at the YAML parse boundary in set_longpress_groups. Co-Authored-By: Claude Opus 4.8 --- common/contexts.py | 12 +++++++++++- pistomp/controller_manager.py | 3 ++- pistomp/footswitch.py | 10 ++++++---- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/common/contexts.py b/common/contexts.py index 935fd1a56..9cd47b110 100644 --- a/common/contexts.py +++ b/common/contexts.py @@ -30,7 +30,7 @@ from dataclasses import dataclass, field from enum import Enum, auto -from typing import Callable, Union +from typing import Callable, TypedDict, Union from common.param_roles import ParamRole from common.parameter import Symbol @@ -139,6 +139,16 @@ class RawMidiCcEffect(Effect): cc: int +class LongpressActionConfig(TypedDict, total=False): + """Mapping-form `longpress:` config, exactly one key (enforced by the schema + in pistomp/config.py). Stays plain data — controller_manager builds the + Effect at bind time, when the footswitch's channel is resolved.""" + + midi_CC: int + preset: int | str # "UP" | "DOWN" | + pedalboard: str # "UP" | "DOWN" + + @dataclass(frozen=True) class TapTempoEffect(Effect): pass diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index 3cb878501..aef8395b4 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -30,6 +30,7 @@ ControlRef, Effect, EventKind, + LongpressActionConfig, MidiCcEffect, ParamEffect, PedalboardEffect, @@ -340,7 +341,7 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None: ) @staticmethod - def _longpress_action_effects(lp: dict, fs: Footswitch) -> tuple[Effect, ...]: + def _longpress_action_effects(lp: LongpressActionConfig, fs: Footswitch) -> tuple[Effect, ...]: """Translate a mapping-form longpress dict into a single-effect tuple. The schema guarantees exactly one key.""" if "midi_CC" in lp: diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index adc43e64e..10f32c31b 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -15,6 +15,8 @@ import logging import sys +from typing import cast + from typing_extensions import override import common.token as Token @@ -23,6 +25,7 @@ import pistomp.gpioswitch as gpioswitch import pistomp.switchstate as switchstate from pistomp.input.event import SwitchEvent, SwitchEventKind +from common.contexts import LongpressActionConfig from common.parameter import BYPASS_SYMBOL @@ -43,9 +46,8 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.category = None self.pixel = pixel self.longpress_groups = [] - # Mapping-form longpress ({midi_CC|preset|pedalboard: ...}); exclusive - # with the chord form. - self.longpress_action: dict | None = None + # Mapping-form longpress; exclusive with the chord form. + self.longpress_action: LongpressActionConfig | None = None self.disabled = False self.taptempo = taptempo @@ -171,7 +173,7 @@ def set_longpress_groups(self, groups): self.longpress_action = None elif isinstance(groups, dict): self.longpress_groups = [] - self.longpress_action = groups + self.longpress_action = cast(LongpressActionConfig, groups) def poll(self): if self.disabled: From 859260a605108e5d0729ad97f23ac99decf6573c Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 01:43:22 -0400 Subject: [PATCH 4/6] Lint fix --- plugins/audio_midi/panel.py | 2 +- plugins/eq/parametric.py | 2 +- plugins/modal_dialog.py | 2 +- plugins/parameter_window.py | 2 +- tests/v3/test_audio_midi_panel.py | 2 -- tests/v3/test_longpress_actions.py | 1 - uilib/arc_dial.py | 1 - uilib/glyphs/audio_midi_tile.py | 1 - 8 files changed, 4 insertions(+), 9 deletions(-) diff --git a/plugins/audio_midi/panel.py b/plugins/audio_midi/panel.py index 385a840cf..06e6ea053 100644 --- a/plugins/audio_midi/panel.py +++ b/plugins/audio_midi/panel.py @@ -62,7 +62,7 @@ from uilib.glyphs.bar import FILL_ACTIVE, FILL_INACTIVE, TRACK_COLOR, paint_bar from uilib.glyphs.circle_handle import paint_circle_handle from uilib.glyphs.pill import PillGlyph -from uilib.misc import INACTIVE_SHADE, InputEvent, get_text_size, shade_color +from uilib.misc import InputEvent, get_text_size, shade_color from uilib.rich_text import IconSeg, RichTextWidget, Segment, Spacer, TextSeg from uilib.text import Button from uilib.widget import Widget diff --git a/plugins/eq/parametric.py b/plugins/eq/parametric.py index e2a9acd9e..177946899 100644 --- a/plugins/eq/parametric.py +++ b/plugins/eq/parametric.py @@ -878,7 +878,7 @@ def edit_symbol(self, symbol: Symbol, rotations: int, multiplier: float = 1.0) - elif symbol == band.freq_sym: current, lo, hi, field_name = p.freq, band.freq_min, band.freq_max, "freq" elif symbol == band.q_sym: - role, current, lo, hi, field_name = ParamRole.Q_FACTOR, p.q, band.q_min, band.q_max, "q" + _role, current, lo, hi, field_name = ParamRole.Q_FACTOR, p.q, band.q_min, band.q_max, "q" # Bandwidth-in-octaves bands invert the knob: clockwise widens, narrowing Q. if band.q_is_bw_oct: rotations = -rotations diff --git a/plugins/modal_dialog.py b/plugins/modal_dialog.py index 63b2f41e5..d45d87f83 100644 --- a/plugins/modal_dialog.py +++ b/plugins/modal_dialog.py @@ -29,7 +29,7 @@ from modalapi.plugin import Plugin from pistomp.handler import Handler from plugins.base import PluginPanel, TState -from plugins.chrome import BTN_GAP, BTN_H, build_bottom_row +from plugins.chrome import BTN_GAP, BTN_H from plugins.scheme import scheme_for_category from uilib.box import Box from uilib.config import Config diff --git a/plugins/parameter_window.py b/plugins/parameter_window.py index f39151eb6..c9ed68af6 100644 --- a/plugins/parameter_window.py +++ b/plugins/parameter_window.py @@ -437,7 +437,7 @@ def _is_dial_enum(param: Parameter) -> bool: if not param.is_ordered_enum(): return False labels = [v["label"] for v in param.enum_values] - return all(len(l) <= 8 for l in labels) + return all(len(label) <= 8 for label in labels) def _heuristic_slots(self) -> list[PinnedParam]: """First up-to-4 params that read as a dial: continuous, a toggle diff --git a/tests/v3/test_audio_midi_panel.py b/tests/v3/test_audio_midi_panel.py index cfaf95cbf..db31da55c 100644 --- a/tests/v3/test_audio_midi_panel.py +++ b/tests/v3/test_audio_midi_panel.py @@ -17,7 +17,6 @@ import pytest -from common.parameter import Symbol from emulator.stubs import StubJackMute from modalapi.sync import SyncMode from pistomp.controller import Controller @@ -381,7 +380,6 @@ def test_transport_stop_clears_flag(self, audio_midi_system: SystemFixture): assert handler.transport_rolling is False def test_tile_swaps_to_rolling_glyph_on_transport_roll(self, audio_midi_system: SystemFixture): - from uilib.glyphs.audio_midi_tile import audio_midi_tile_glyph handler = audio_midi_system.handler handler.lcd.draw_main_panel() diff --git a/tests/v3/test_longpress_actions.py b/tests/v3/test_longpress_actions.py index 20f7dfeb5..7f800c1cf 100644 --- a/tests/v3/test_longpress_actions.py +++ b/tests/v3/test_longpress_actions.py @@ -11,7 +11,6 @@ from common.contexts import ( ControlClass, EventKind, - PedalboardEffect, PresetEffect, RawMidiCcEffect, ) diff --git a/uilib/arc_dial.py b/uilib/arc_dial.py index 63523d2c6..7ea6658bb 100644 --- a/uilib/arc_dial.py +++ b/uilib/arc_dial.py @@ -33,7 +33,6 @@ from __future__ import annotations -import math from enum import Enum from typing import Callable, Literal diff --git a/uilib/glyphs/audio_midi_tile.py b/uilib/glyphs/audio_midi_tile.py index bd0713ed7..bb77a12a7 100644 --- a/uilib/glyphs/audio_midi_tile.py +++ b/uilib/glyphs/audio_midi_tile.py @@ -45,7 +45,6 @@ import numpy as np import pygame -from uilib.glyphs.tint import tint_mask State = Literal["nominal", "muted", "rolling"] From c7012da0f47331238d508748e0e71e248c41adef Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 13:40:16 -0400 Subject: [PATCH 5/6] Support extra_data on dynamically-added plugins --- modalapi/modhandler.py | 43 ++++++++++++ modalapi/ws_protocol.py | 25 +++++++ plugins/customization.py | 30 ++++++++- plugins/nam/__init__.py | 11 ++++ plugins/notes/panel.py | 21 +++++- tests/test_ws_protocol.py | 39 +++++++++++ tests/v3/test_plugin_extra_data.py | 45 ++++++++++++- tests/v3/test_plugins.py | 102 +++++++++++++++++++++++++++++ 8 files changed, 310 insertions(+), 6 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 81c7ea2ef..d2af73257 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -30,6 +30,7 @@ import yaml from collections import namedtuple from collections.abc import Callable +from dataclasses import replace import functools from functools import cached_property from typing import cast, Any @@ -67,6 +68,7 @@ # injected into Pedalboard as its Customizer. from plugins.base import PluginPanel from plugins.customization import lookup as plugin_lookup +from plugins.customization import patch_extra_data import modalapi.external_midi as ExternalMidi from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute @@ -83,6 +85,7 @@ PluginBypassMessage, TransportMessage, AddPluginMessage, + PatchSetMessage, RemovePluginMessage, ConnectMessage, DisconnectMessage, @@ -181,6 +184,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") # instance_id. Applied after the new board loads when the dump and the # last.json reload land in the same poll tick. self._pending_dump_bypass: dict[str, bool] = {} + self._pending_dump_patch: dict[tuple[str, str], str] = {} # Backup self.backup_file = "pistomp_backup.zip" @@ -735,6 +739,7 @@ def _handle_ws_message(self, msg: WebSocketMessage): if isinstance(msg, LoadingStartMessage): self._is_pedalboard_loading = True self._pending_dump_bypass.clear() + self._pending_dump_patch.clear() cleared = self.ws_bridge.clear_queue() if cleared: logging.debug(f"Cleared {cleared} stale outbound messages on loading_start") @@ -875,6 +880,35 @@ def _handle_ws_message(self, msg: WebSocketMessage): # MIDI learn in mod-ui assigned a hardware control to a parameter. self._apply_midi_binding(msg.instance, msg.symbol, msg.binding) + elif isinstance(msg, PatchSetMessage): + self._handle_patch_set(msg) + + @staticmethod + def _apply_patch(plugin: Plugin, param_uri: str, value: str) -> bool: + """Refresh one plugin's extra_data. False if nothing owns this property + or the value is unchanged.""" + extra = patch_extra_data(plugin.uri, param_uri, value) + if extra is None or extra == plugin.customization.extra_data: + return False + plugin.customization = replace(plugin.customization, extra_data=extra) + return True + + def _handle_patch_set(self, msg: PatchSetMessage) -> None: + """A plugin's writable property changed. This is the only source of extra + data for a freshly added plugin — it has no effect-N bundle on disk until + the board is saved.""" + # Buffer for the connect-dump race, same as bypass: the dump can drain + # before last.json reload sets current. + self._pending_dump_patch[(msg.instance, msg.param_uri)] = msg.value + if self._current is None: + return + plugin = next( + (p for p in self.current.pedalboard.plugins if p.instance_id == msg.instance), + None, + ) + if plugin is not None and self._apply_patch(plugin, msg.param_uri, msg.value): + self.lcd.draw_main_panel() + def _handle_dynamic_plugin_add(self, msg: AddPluginMessage) -> None: """Handle an `add` WS message for a plugin not yet in the pedalboard model.""" assert self._current is not None @@ -1063,6 +1097,15 @@ def set_current_pedalboard(self, pedalboard): plugin.set_bypass(self._pending_dump_bypass[plugin.instance_id]) self._pending_dump_bypass.clear() + # Same race, same scoping: only instances present on the board being + # made current are applied; anything else is dropped with the buffer. + if self._pending_dump_patch: + for plugin in pedalboard.plugins: + for (instance, param_uri), value in self._pending_dump_patch.items(): + if instance == plugin.instance_id: + self._apply_patch(plugin, param_uri, value) + self._pending_dump_patch.clear() + # Load Pedalboard specific config (overrides default set during initial hardware init) config_file = Path(pedalboard.bundle) / "config.yml" cfg = None diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py index b5f9a4219..c6bc68842 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -121,6 +121,18 @@ class AddPluginMessage: bypassed: bool +@dataclass +class PatchSetMessage: + """A plugin's writable property changed (`patch_set ...`). Replayed in full + on the connect dump, so it also carries state for boards loaded before we + connected.""" + + instance: str # canonical bare form, e.g. "notes" + param_uri: str # LV2 property URI + value_type: str # mod-host type char: s(tring) p(ath) i(nt) f(loat) ... + value: str # raw; paths may contain spaces + + @dataclass class RemovePluginMessage: """Plugin dynamically removed from the active pedalboard (remove ...).""" @@ -187,6 +199,7 @@ class UnknownMessage: PluginBypassMessage, TransportMessage, AddPluginMessage, + PatchSetMessage, RemovePluginMessage, ConnectMessage, DisconnectMessage, @@ -265,6 +278,18 @@ def parse_message(raw_message: str) -> WebSocketMessage: bypassed=int(parts[3]) != 0, ) + # Format: patch_set {instance} {writable} {paramUri} {valueType} {value} + case ["patch_set", instance_path, rest]: + parts = rest.split(" ", 3) + if len(parts) < 4: + return UnknownMessage(raw=raw_message) + return PatchSetMessage( + instance=instance_path.removeprefix("/graph/"), + param_uri=parts[1], + value_type=parts[2], + value=parts[3], + ) + # Format: remove {instance} case ["remove", instance_path]: return RemovePluginMessage(instance=instance_path.removeprefix("/graph/")) diff --git a/plugins/customization.py b/plugins/customization.py index da55559b1..2c7a0b248 100644 --- a/plugins/customization.py +++ b/plugins/customization.py @@ -9,19 +9,35 @@ from common.parameter import Symbol from modalapi.plugin_customization import PluginCustomization, PluginExtraData -__all__ = ["PluginCustomization", "register", "hide_params", "lookup", "registered_uris"] +__all__ = [ + "PluginCustomization", + "register", + "hide_params", + "lookup", + "patch_extra_data", + "registered_uris", +] ExtraDataParser = Callable[[str], PluginExtraData | None] +# (property URI, value) -> extra data. Returns None for properties it doesn't own. +PatchDataParser = Callable[[str, str], PluginExtraData | None] _URI_MAP: dict[str, tuple[PluginCustomization, ExtraDataParser | None]] = {} +_PATCH_MAP: dict[str, PatchDataParser] = {} def register( *uris: str, customization: PluginCustomization, extra_data_fn: ExtraDataParser | None = None, + patch_data_fn: PatchDataParser | None = None, ) -> None: - """`extra_data_fn` (if given) is called by `lookup` with the plugin's `effect.ttl` contents to populate `customization.extra_data`.""" + """`extra_data_fn` (if given) is called by `lookup` with the plugin's `effect.ttl` contents to populate `customization.extra_data`. + + `patch_data_fn` does the same from a live `patch_set` value. A freshly added + plugin has no `effect-N` bundle until the board is saved, so it's the only + route to extra data for one. + """ for uri in uris: prior, _ = _URI_MAP.get(uri, (None, None)) if prior is not None and prior.hidden_params: @@ -29,6 +45,8 @@ def register( customization, hidden_params=customization.hidden_params | prior.hidden_params ) _URI_MAP[uri] = (customization, extra_data_fn) + if patch_data_fn is not None: + _PATCH_MAP[uri] = patch_data_fn def hide_params(*uris: str, symbols: frozenset[Symbol]) -> None: @@ -62,5 +80,13 @@ def lookup( return customization +def patch_extra_data(uri: str | None, param_uri: str, value: str) -> PluginExtraData | None: + """Extra data for a live `patch_set`, or None if nothing owns this property.""" + if not uri: + return None + parser = _PATCH_MAP.get(uri) + return parser(param_uri, value) if parser is not None else None + + def registered_uris() -> frozenset[str]: return frozenset(_URI_MAP) diff --git a/plugins/nam/__init__.py b/plugins/nam/__init__.py index 5cf4168bf..ef34e15ca 100644 --- a/plugins/nam/__init__.py +++ b/plugins/nam/__init__.py @@ -39,11 +39,21 @@ class NamData(PluginExtraData): model_path: str +_MODEL_URI = "http://github.com/mikeoliphant/neural-amp-modeler-lv2#model" + + def _parse_nam(ttl: str) -> NamData | None: m = _MODEL_RE.search(ttl) return NamData(model_path=m.group(1)) if m else None +def _patch_nam(param_uri: str, value: str) -> NamData | None: + # An unloaded slot patches an empty path; keep the generic name over "". + if param_uri != _MODEL_URI or not value: + return None + return NamData(model_path=value) + + def _model_filename(plugin: Plugin) -> str | None: data = extra_data_as(plugin, NamData) if data is None: @@ -86,4 +96,5 @@ def _nam_subtitle(plugin: Plugin) -> str | None: ), ), extra_data_fn=_parse_nam, + patch_data_fn=_patch_nam, ) diff --git a/plugins/notes/panel.py b/plugins/notes/panel.py index 9ae55694d..48827401b 100644 --- a/plugins/notes/panel.py +++ b/plugins/notes/panel.py @@ -19,7 +19,9 @@ from uilib.text import TextWidget from uilib.widget import Widget -_NOTES_RE = re.compile(r'<[^>]*notes#text>\s+"""(.*?)"""', re.DOTALL) +_NOTES_URI = "http://open-music-kontrollers.ch/lv2/notes#text" +# Turtle only long-quotes when the text needs it; a one-line note is plain-quoted. +_NOTES_RE = re.compile(r'<[^>]*notes#text>\s+(?:"""(.*?)"""|"((?:[^"\\]|\\.)*)")', re.DOTALL) @dataclass(frozen=True) @@ -29,9 +31,23 @@ class NotesData(PluginExtraData): text: str +_TTL_ESCAPES = {"n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\"} + + +def _unescape(s: str) -> str: + return re.sub(r"\\(.)", lambda m: _TTL_ESCAPES.get(m.group(1), m.group(1)), s) + + def _parse_notes(ttl: str) -> NotesData | None: m = _NOTES_RE.search(ttl) - return NotesData(text=m.group(1)) if m else None + if m is None: + return None + long_form, short_form = m.group(1), m.group(2) + return NotesData(text=long_form if long_form is not None else _unescape(short_form)) + + +def _patch_notes(param_uri: str, value: str) -> NotesData | None: + return NotesData(text=value) if param_uri == _NOTES_URI else None def _notes_text(plugin: Plugin) -> str: @@ -194,4 +210,5 @@ def _notes_shortname(plugin: Plugin) -> str | None: tile_active_color=(214, 217, 111), ), extra_data_fn=_parse_notes, + patch_data_fn=_patch_notes, ) diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py index 413e0a461..95b40ce39 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -1,6 +1,7 @@ """Unit tests for ws_protocol.parse_message.""" from modalapi.ws_protocol import ( + PatchSetMessage, AddHwPortMessage, AddPluginMessage, ConnectMessage, @@ -377,3 +378,41 @@ def test_malformed_pedal_snapshot_non_int(): def test_empty_string(): msg = parse_message("") assert isinstance(msg, UnknownMessage) + + +# --------------------------------------------------------------------------- +# patch_set — writable plugin properties (frames captured off a live device) +# --------------------------------------------------------------------------- + + +def test_patch_set_string_value(): + msg = parse_message("patch_set /graph/notes 1 http://open-music-kontrollers.ch/lv2/notes#text s Abc123") + assert msg == PatchSetMessage( + instance="notes", + param_uri="http://open-music-kontrollers.ch/lv2/notes#text", + value_type="s", + value="Abc123", + ) + + +def test_patch_set_path_value_keeps_spaces(): + msg = parse_message( + "patch_set /graph/neural_amp_modeler_lv2_1 1 " + "http://github.com/mikeoliphant/neural-amp-modeler-lv2#model p " + "/home/pistomp/data/user-files/NAM Models/Clean (G1 L0 B1 T1).nam" + ) + assert msg == PatchSetMessage( + instance="neural_amp_modeler_lv2_1", + param_uri="http://github.com/mikeoliphant/neural-amp-modeler-lv2#model", + value_type="p", + value="/home/pistomp/data/user-files/NAM Models/Clean (G1 L0 B1 T1).nam", + ) + + +def test_patch_set_empty_value(): + msg = parse_message("patch_set /graph/nam 1 http://uri#model p ") + assert msg == PatchSetMessage(instance="nam", param_uri="http://uri#model", value_type="p", value="") + + +def test_patch_set_truncated_is_unknown(): + assert isinstance(parse_message("patch_set /graph/nam 1 http://uri#model"), UnknownMessage) diff --git a/tests/v3/test_plugin_extra_data.py b/tests/v3/test_plugin_extra_data.py index d924845e2..5f46041da 100644 --- a/tests/v3/test_plugin_extra_data.py +++ b/tests/v3/test_plugin_extra_data.py @@ -7,8 +7,8 @@ from __future__ import annotations -from plugins.nam import NamData, _parse_nam -from plugins.notes.panel import NotesData, _parse_notes +from plugins.nam import NamData, _parse_nam, _patch_nam +from plugins.notes.panel import NotesData, _parse_notes, _patch_notes # ── NAM ─────────────────────────────────────────────────────────────────────── @@ -41,3 +41,44 @@ def test_notes_parser_returns_none_when_no_text_triple() -> None: def test_notes_parser_returns_none_for_empty_ttl() -> None: assert _parse_notes("") is None + + +def test_notes_parser_accepts_single_line_short_quotes() -> None: + # Turtle only long-quotes when it has to; mod-ui writes one-liners plain. + ttl = ' "Abc123" .\n' + assert _parse_notes(ttl) == NotesData(text="Abc123") + + +def test_notes_parser_unescapes_short_quoted_text() -> None: + ttl = r' "say \"hi\"\nbye" .' + "\n" + assert _parse_notes(ttl) == NotesData(text='say "hi"\nbye') + + +# ── patch_set parsers ───────────────────────────────────────────────────────── + + +def test_nam_patch_parser_takes_model_path() -> None: + assert _patch_nam( + "http://github.com/mikeoliphant/neural-amp-modeler-lv2#model", + "/home/pistomp/data/user-files/NAM Models/Clean (G1 L0 B1 T1).nam", + ) == NamData(model_path="/home/pistomp/data/user-files/NAM Models/Clean (G1 L0 B1 T1).nam") + + +def test_nam_patch_parser_ignores_other_properties() -> None: + assert _patch_nam("http://example.com/other", "/models/x.nam") is None + + +def test_nam_patch_parser_ignores_empty_model() -> None: + # An unloaded NAM slot patches "" — keep the generic tile name. + assert _patch_nam("http://github.com/mikeoliphant/neural-amp-modeler-lv2#model", "") is None + + +def test_notes_patch_parser_takes_text_verbatim() -> None: + # Wire values are unquoted already — no Turtle escaping to undo. + assert _patch_notes("http://open-music-kontrollers.ch/lv2/notes#text", "Abc123") == NotesData( + text="Abc123" + ) + + +def test_notes_patch_parser_ignores_other_properties() -> None: + assert _patch_notes("http://open-music-kontrollers.ch/lv2/notes#fontHeight", "25") is None diff --git a/tests/v3/test_plugins.py b/tests/v3/test_plugins.py index d166682fd..df0e65773 100644 --- a/tests/v3/test_plugins.py +++ b/tests/v3/test_plugins.py @@ -897,3 +897,105 @@ def test_v3_websocket_bypass_event_with_multiword_id(v3_system): handler.poll_modui_changes() assert plugin.is_bypassed() + + +# --------------------------------------------------------------------------- +# patch_set -> extra_data +# --------------------------------------------------------------------------- + +_NAM_URI = "http://github.com/mikeoliphant/neural-amp-modeler-lv2" +_NAM_MODEL = f"{_NAM_URI}#model" + + +def test_v3_live_patch_set_names_nam_tile(v3_system: SystemFixture, make_plugin): + """A mid-session model change renames the tile without a board reload.""" + handler = v3_system.handler + nam = make_plugin("nam", category="Simulator", uri=_NAM_URI) + assert handler.current + handler.current.pedalboard.plugins = [nam] + generic = nam.display_name + + v3_system.ws_bridge.inject(f"patch_set /graph/nam 1 {_NAM_MODEL} p /models/Marshall JCM800.nam") + handler.poll_ws_messages() + + assert nam.display_name == "Marshall JCM800" + assert nam.display_name != generic + assert nam.subtitle == "NAM: Marshall JCM800.nam" + + +def test_v3_patch_set_ignores_unowned_property(v3_system: SystemFixture, make_plugin): + handler = v3_system.handler + nam = make_plugin("nam", category="Simulator", uri=_NAM_URI) + assert handler.current + handler.current.pedalboard.plugins = [nam] + + v3_system.ws_bridge.inject(f"patch_set /graph/nam 1 {_NAM_URI}#unrelated s whatever") + handler.poll_ws_messages() + + assert nam.customization.extra_data is None + + +def test_v3_patch_set_for_unknown_instance_is_harmless(v3_system: SystemFixture, make_plugin): + """Dump frames can name instances that aren't on the board — must not raise.""" + handler = v3_system.handler + nam = make_plugin("nam", category="Simulator", uri=_NAM_URI) + assert handler.current + handler.current.pedalboard.plugins = [nam] + + v3_system.ws_bridge.inject(f"patch_set /graph/ghost 1 {_NAM_MODEL} p /models/Ghost.nam") + handler.poll_ws_messages() + + assert nam.customization.extra_data is None + + +def test_v3_dump_patch_set_same_tick_applies_to_new_board(v3_system: SystemFixture, make_plugin): + """Same-tick race, patch_set flavour: the dump drains before last.json reload + switches the board, so the model must be buffered and flushed into the new one.""" + handler = v3_system.handler + + nam = make_plugin("nam", category="Simulator", uri=_NAM_URI) + new_pb = handler.pedalboards["/path/to/new.pedalboard"] + new_pb.plugins = [nam] + handler.reload_pedalboard = lambda bundle: new_pb + + ws_bridge = v3_system.ws_bridge + ws_bridge.inject("loading_start 0") + ws_bridge.inject(f"add nam {_NAM_URI} 0.0 0.0 0 1 1") + ws_bridge.inject(f"patch_set /graph/nam 1 {_NAM_MODEL} p /models/Marshall JCM800.nam") + ws_bridge.inject("loading_end 0") + + last_json = Path(handler.data_dir) / "last.json" + last_json.write_text(json.dumps({"pedalboard": "/path/to/new.pedalboard"})) + os.utime(last_json, (9999, 9999)) + + handler.poll_modui_changes() + + assert handler.current + assert handler.current.pedalboard.bundle == "/path/to/new.pedalboard" + assert nam.display_name == "Marshall JCM800" + assert not handler._pending_dump_patch + + +def test_v3_loading_start_discards_previous_boards_patches(v3_system: SystemFixture, make_plugin): + """A patch buffered for board A must not land on board B.""" + handler = v3_system.handler + + nam = make_plugin("nam", category="Simulator", uri=_NAM_URI) + new_pb = handler.pedalboards["/path/to/new.pedalboard"] + new_pb.plugins = [nam] + handler.reload_pedalboard = lambda bundle: new_pb + + ws_bridge = v3_system.ws_bridge + ws_bridge.inject(f"patch_set /graph/nam 1 {_NAM_MODEL} p /models/Stale.nam") + ws_bridge.inject("loading_start 0") + ws_bridge.inject(f"add nam {_NAM_URI} 0.0 0.0 0 1 1") + ws_bridge.inject("loading_end 0") + + last_json = Path(handler.data_dir) / "last.json" + last_json.write_text(json.dumps({"pedalboard": "/path/to/new.pedalboard"})) + os.utime(last_json, (9999, 9999)) + + handler.poll_modui_changes() + + assert nam.customization.extra_data is None + assert nam.display_name != "Stale" From ad6110341178099350e7639b5d410735f20b243b Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 19 Jul 2026 02:38:24 -0400 Subject: [PATCH 6/6] Restore INACTIVE_SHADE --- plugins/audio_midi/panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/audio_midi/panel.py b/plugins/audio_midi/panel.py index cb0442e22..618f6d72c 100644 --- a/plugins/audio_midi/panel.py +++ b/plugins/audio_midi/panel.py @@ -63,7 +63,7 @@ from uilib.glyphs.bar import FILL_ACTIVE, FILL_INACTIVE, TRACK_COLOR, paint_bar from uilib.glyphs.circle_handle import paint_circle_handle from uilib.glyphs.pill import PillGlyph -from uilib.misc import InputEvent, get_text_size, shade_color +from uilib.misc import INACTIVE_SHADE, InputEvent, get_text_size, shade_color from uilib.rich_text import IconSeg, RichTextWidget, Segment, Spacer, TextSeg from uilib.text import Button from uilib.widget import Widget