Skip to content
43 changes: 43 additions & 0 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -83,6 +85,7 @@
PluginBypassMessage,
TransportMessage,
AddPluginMessage,
PatchSetMessage,
RemovePluginMessage,
ConnectMessage,
DisconnectMessage,
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions modalapi/ws_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ...)."""
Expand Down Expand Up @@ -187,6 +199,7 @@ class UnknownMessage:
PluginBypassMessage,
TransportMessage,
AddPluginMessage,
PatchSetMessage,
RemovePluginMessage,
ConnectMessage,
DisconnectMessage,
Expand Down Expand Up @@ -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/"))
Expand Down
30 changes: 28 additions & 2 deletions plugins/customization.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,44 @@
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:
customization = replace(
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:
Expand Down Expand Up @@ -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)
11 changes: 11 additions & 0 deletions plugins/nam/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -86,4 +96,5 @@ def _nam_subtitle(plugin: Plugin) -> str | None:
),
),
extra_data_fn=_parse_nam,
patch_data_fn=_patch_nam,
)
21 changes: 19 additions & 2 deletions plugins/notes/panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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,
)
39 changes: 39 additions & 0 deletions tests/test_ws_protocol.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Unit tests for ws_protocol.parse_message."""

from modalapi.ws_protocol import (
PatchSetMessage,
AddHwPortMessage,
AddPluginMessage,
ConnectMessage,
Expand Down Expand Up @@ -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)
45 changes: 43 additions & 2 deletions tests/v3/test_plugin_extra_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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 = '<file://plugin> <http://open-music-kontrollers.ch/lv2/notes#text> "Abc123" .\n'
assert _parse_notes(ttl) == NotesData(text="Abc123")


def test_notes_parser_unescapes_short_quoted_text() -> None:
ttl = r'<x> <http://open-music-kontrollers.ch/lv2/notes#text> "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
Loading
Loading