From c7012da0f47331238d508748e0e71e248c41adef Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 18 Jul 2026 13:40:16 -0400 Subject: [PATCH] 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"