Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7b4dca2
New longpress actions
sastraxi Jul 18, 2026
1f72c51
Trim longpress comments
sastraxi Jul 18, 2026
29e6485
Type longpress_action as a TypedDict
sastraxi Jul 18, 2026
859260a
Lint fix
sastraxi Jul 18, 2026
c7012da
Support extra_data on dynamically-added plugins
sastraxi Jul 18, 2026
26a65e0
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 18, 2026
2cce702
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 18, 2026
c25b206
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 19, 2026
cc40812
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 19, 2026
0665ed6
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 19, 2026
8544668
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 19, 2026
ad61103
Restore INACTIVE_SHADE
sastraxi Jul 19, 2026
995d2e6
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 19, 2026
8510328
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 19, 2026
32598ab
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 19, 2026
b9e1997
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 19, 2026
1e21677
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 19, 2026
cca6dc5
Merge branch 'feat/audio-midi-menu' into feat/new-longpress-actions
sastraxi Jul 20, 2026
ab3af7f
Merge branch 'feat/new-longpress-actions' into fix/consume-patch-set
sastraxi Jul 20, 2026
bcef643
Merge pull request #212 from TreeFallSound/fix/consume-patch-set
sastraxi Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 23 additions & 1 deletion common/contexts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -127,6 +127,28 @@ class PresetEffect(Effect):
direction: str # "UP" | "DOWN" | "<int>"


@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


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" | <index>
pedalboard: str # "UP" | "DOWN"


@dataclass(frozen=True)
class TapTempoEffect(Effect):
pass
Expand Down
110 changes: 110 additions & 0 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
import subprocess
import sys
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 All @@ -48,7 +50,9 @@
EventKind,
MidiCcEffect,
ParamEffect,
PedalboardEffect,
PresetEffect,
RawMidiCcEffect,
RelayEffect,
TapTempoEffect,
)
Expand All @@ -64,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 @@ -80,6 +85,7 @@
PluginBypassMessage,
TransportMessage,
AddPluginMessage,
PatchSetMessage,
RemovePluginMessage,
ConnectMessage,
DisconnectMessage,
Expand Down Expand Up @@ -122,6 +128,11 @@ 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; tracks what value to
send next. mod-ui's echo reconciles the learned plugin."""


class Modhandler(Handler):
__single = None

Expand Down Expand Up @@ -173,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 @@ -222,6 +234,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
Expand All @@ -235,6 +249,9 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
# Footswitch longpress/chord resolver (rebuilt on pedalboard change)
self.chord_helper = FootswitchChords()

# First raw-CC longpress sends 127; alternates thereafter.
self._longpress_cc_state: dict[LongpressCcKey, bool] = {}

def cleanup(self):
if self._tuner_muted:
self.audiocard.set_output_muted(False)
Expand Down Expand Up @@ -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, 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:
"""Send a CC. Tries the external port if routed; falls back to virtual."""
if controller.midi_CC is None:
Expand Down Expand Up @@ -708,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 @@ -848,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 @@ -1036,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 Expand Up @@ -1149,6 +1219,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
#
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
34 changes: 29 additions & 5 deletions pistomp/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading