Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 16 additions & 7 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,9 +342,12 @@ never authoritatively sets its own copy. A persistent daemon-thread bridge
(`poll_ws_messages`, ~10ms). `output_set` meter/scope spam is dropped at the bridge.

Inbound (`parse_message` → typed messages → each handler's `_handle_ws_message`):
- `param_set …/:bypass v` — live bypass delta → set bypass + redraw.
- `param_set …/{sym} v` — control value → refresh cached `Parameter.value` (a later
edit opens at current); no live redraw.
- `param_set …/:bypass v` — live bypass delta → set bypass + redraw. Routed through
`Plugin.set_param_value`, which also reconciles any bound footswitch's indicators.
- `param_set …/{sym} v` — control value → `Plugin.set_param_value` refreshes the cached
`Parameter.value` (a later edit opens at current) and mirrors it onto any control
bound to that param: a footswitch redraws its LED/keycap; a knob/encoder updates its
cached position. Params bound to nothing do no work.
- `add {inst} {uri} … {bypassed} …` — appears **only** in the (re)connect/load dump;
bypass rides in field 4 — its sole arrival point on connect. Same bypass dispatch.
- `loading_start` / `loading_end {snapshot}` — bracket a dump; `loading_end` stashes
Expand All @@ -360,8 +363,11 @@ Outbound behaviour depends on the initiator:

- **Footswitch press** → MIDI CC (absolute `toggled` intent) → mod-host processes
internally → mod-host emits `param_set` feedback on port 5556 → `msg_callback` to
ALL clients (including us). Emit-only is correct here; the feedback echo drives the
LCD/LED update.
ALL clients (including us). piStomp updates its **LED optimistically on press**;
the feedback echo drives the LCD/plugin state update and reconciles if it differs.
Waiting for the echo would lag the switch whenever mod-host's feedback stream is
gated on a slow client — the `data_finish`/`output_data_ready` handshake stalls when
a mod-ui browser tab is backgrounded (see `../mod-ui/docs/output-data-flow.md`).
- **Non-footswitch UI tap** → WS `send_parameter` → mod-ui calls `host.bypass()` →
`msg_callback_broadcast` **skips the origin socket** (us), and mod-host does NOT
generate `param_set` feedback for `bypass` commands it received from mod-ui. No echo
Expand Down Expand Up @@ -457,15 +463,18 @@ changes (not for commands mod-ui itself issued). This determines who sees what:
```
Path A — Footswitch (MIDI CC):
poll_controls() → Footswitch.pressed()
→ flip toggled, _set_led() # LED-only optimistic update
→ midiout.send_message([CC, midi_CC, 127/0]) # direct to ALSA
→ JACK → mod-host:midi_in # bypasses mod-ui WS entirely
→ mod-host applies change
→ mod-host emits param_set feedback on port 5556
→ mod-ui process_read_message_body
→ msg_callback("param_set /graph/X :bypass V") # ALL clients, no skip
→ pi-stomp poll_ws_messages() receives it
→ plugin.set_bypass() + lcd.refresh_plugins()
Emit-only is correct: pi-stomp waits for the feedback echo to update LCD/LED.
→ plugin.set_bypass() + lcd.refresh_plugins() # reconciles
Optimistic update keeps the switch responsive even when the echo is delayed by
mod-host's data_finish/output_data_ready handshake (stalls on a backgrounded
mod-ui browser tab); the later echo is authoritative and corrects any divergence.

Path B — Non-footswitch UI tap (LCD plugin widget click):
toggle_plugin_bypass(widget, plugin)
Expand Down
10 changes: 5 additions & 5 deletions modalapi/mod.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,14 +524,14 @@ def _handle_ws_message(self, msg: WebSocketMessage):
self.update_lcd_fs(footswitch=fs)

elif isinstance(msg, ParamSetMessage):
# Keep the cached value fresh so a later edit opens at the current
# value. Not drawn anywhere live, so no LCD refresh.
# Mirror mod-ui's live value: refresh the cache (so a later edit opens
# at the current value) and sync any bound control. The connect-dump
# delivers the real mod-ui state here — :bypass aside, nothing else
# repaints a non-bypass footswitch.
if self.current is not None:
for plugin in self.current.pedalboard.plugins:
if plugin.instance_id == msg.instance:
param = plugin.parameters.get(msg.symbol)
if param is not None:
param.value = msg.value
plugin.set_param_value(msg.symbol, msg.value)
break

elif isinstance(msg, MidiMapMessage):
Expand Down
10 changes: 5 additions & 5 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -395,14 +395,14 @@ def _handle_ws_message(self, msg: WebSocketMessage):
self.update_lcd_fs(footswitch=fs)

elif isinstance(msg, ParamSetMessage):
# Keep the cached value fresh so a later long-press edit opens at the
# current value. Not drawn anywhere live, so no LCD refresh.
# Mirror mod-ui's live value: refresh the cache (so a later edit opens
# at the current value) and sync any bound control. The connect-dump
# delivers the real mod-ui state here — :bypass aside, nothing else
# repaints a non-bypass footswitch.
if self.current is not None:
for plugin in self.current.pedalboard.plugins:
if plugin.instance_id == msg.instance:
param = plugin.parameters.get(msg.symbol)
if param is not None:
param.value = msg.value
plugin.set_param_value(msg.symbol, msg.value)
break

elif isinstance(msg, MidiMapMessage):
Expand Down
23 changes: 14 additions & 9 deletions modalapi/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
import json

from common.parameter import Parameter
from pistomp.footswitch import Footswitch
from pistomp.controller import Controller

Point = tuple[int, int]

Expand All @@ -40,7 +40,7 @@ def __init__(
self.parameters: dict[str, Parameter] = parameters
self.bypass_indicator_xy: tuple[Point, Point] = ((0, 0), (0, 0))
self.lcd_xyz: LcdPosition | None = None
self.controllers: list[Footswitch] = []
self.controllers: list[Controller] = []
self.has_footswitch: bool = False
self.category: str | None = category

Expand All @@ -58,15 +58,20 @@ def toggle_bypass(self) -> float:
param.value = new_value
return new_value

def set_bypass(self, bypass: bool) -> None:
param = self.parameters.get(":bypass")
def set_param_value(self, symbol: str, value: float) -> None:
"""Cache a param's value and mirror it onto any control bound to it, so
a footswitch's LED/keycap (or a knob/encoder's cached position) tracks
mod-ui's live value. set_value is polymorphic per control type."""
param = self.parameters.get(symbol)
if param is None:
return
param.value = 1.0 if bypass else 0.0
if self.has_footswitch:
for c in self.controllers:
if isinstance(c, Footswitch):
c.set_value(param.value)
param.value = value
for c in self.controllers:
if c.parameter is param:
c.set_value(value)

def set_bypass(self, bypass: bool) -> None:
self.set_param_value(":bypass", 1.0 if bypass else 0.0)

def to_json(self) -> str:
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)
7 changes: 5 additions & 2 deletions pistomp/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
from enum import Enum
import json
import logging
from typing import Optional

from common.parameter import Parameter


class Controller:
Expand All @@ -27,7 +30,7 @@ def __init__(self, midi_channel, midi_CC):
self.midi_CC = midi_CC
self.minimum = None
self.maximum = None
self.parameter = None
self.parameter: Optional[Parameter] = None
self.hardware_name = None
#self.type = None # this will conflict with encoder.type for EncoderMidiControl
self.midi_min = 0
Expand All @@ -36,7 +39,7 @@ def __init__(self, midi_channel, midi_CC):
def to_json(self):
return json.dumps(self, default=lambda o: o.__dict__, sort_keys=True, indent=4)

def set_value(self, bypass_value: float):
def set_value(self, value: float):
logging.error("Controller subclass hasn't overriden the set_value method")


28 changes: 15 additions & 13 deletions pistomp/footswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import logging
import time
import sys
from typing import Any, Callable
from rtmidi.midiconstants import CONTROL_CHANGE

import common.token as Token
Expand Down Expand Up @@ -93,8 +94,8 @@ def __init__(self, id, led_pin, pixel, midi_CC, midi_channel, midiout, refresh_c
self.display_label = None
self.toggled = False
self.led = None
self.midiout = midiout
self.refresh_callback = refresh_callback
self.midiout: Any = midiout
self.refresh_callback: Callable[..., Any] = refresh_callback
self.relay_list = []
self.preset_callback = None
self.preset_callback_arg = None
Expand Down Expand Up @@ -143,12 +144,16 @@ def set_midi_channel(self, midi_channel):

@property
def drives_display(self) -> bool:
"""True when unbound: no inbound echo will arrive, so the press updates
indicators itself. When bound to a plugin :bypass, the WS broadcast does."""
return self.parameter is None

def set_value(self, bypass_value: float):
self.toggled = (bypass_value < 1)
def set_value(self, value: float):
param = self.parameter
if param is not None and param.symbol != Token.COLON_BYPASS:
lo = param.minimum if param.minimum is not None else 0
hi = param.maximum if param.maximum is not None else 1
self.toggled = value >= (lo + hi) / 2
else:
self.toggled = (value < 1)
self.set_led(self.toggled)
self.refresh_callback(footswitch=self)

Expand Down Expand Up @@ -209,11 +214,7 @@ def _log_longpress_events(self):
info.timestamps.update({self.id: now})

def pressed(self, state):
# If a footswitch can be mapped to control a relay, preset, MIDI or all 3
#
# The footswitch will only "toggle" if it's associated with a relay
# (in which case it will toggle with the relay) or with a Midi message
#
"""Handle a footswitch press: route to relay, preset, or MIDI CC as configured."""
new_toggled = not self.toggled

# First handle Longpress Events
Expand All @@ -238,7 +239,7 @@ def pressed(self, state):
# Now short Press Events

if self.taptempo and self.taptempo.is_enabled():
pass # Don't process other events when in taptempo mode
return # Don't process other events when in taptempo mode

# If mapped to preset change
elif self.preset_callback is not None:
Expand Down Expand Up @@ -267,7 +268,7 @@ def set_display_label(self, label):

def add_relay(self, relay):
self.relay_list.append(relay)
self.set_value(not relay.init_state())
self.set_value(0.0 if relay.init_state() else 1.0)

def clear_relays(self):
self.relay_list.clear()
Expand All @@ -280,6 +281,7 @@ def clear_pedalboard_info(self):
self.toggled = False
self.disabled = False
self.display_label = None
self.parameter = None
self.set_category(None)
self.preset_callback = None
self.preset_callback_arg = None
Expand Down
7 changes: 2 additions & 5 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -490,26 +490,23 @@ def footswitch_label(self, footswitch):
return self.shorten_name(param.instance_id, self.footswitch_width)

def draw_footswitch(self, plugin):
color = self.get_plugin_color(plugin)
for c in plugin.controllers:
if isinstance(c, Footswitch):
if c.preset_callback_arg is not None:
# Preset-bound switches are drawn by draw_unbound_footswitches,
# regardless of any (stale) plugin binding.
continue
fs_id = c.id
#fss[fs_id] = None
label = self.footswitch_label(c)
c.set_display_label(label)

y = 0
x = self.get_footswitch_pitch() * fs_id
self.footswitch_slots[fs_id] = label
color = self.get_plugin_color(plugin)
p = FootswitchWidget(Box.xywh(x, y, self.plugin_width, self.footswitch_height), self.small_font,
label, color, plugin.is_bypassed(), parent=self.footswitch_panel, object=c)
label, color, not c.toggled, parent=self.footswitch_panel, object=c)
self.w_footswitches.append(p)
self.footswitch_panel.add_widget(p)
break

def draw_unbound_footswitches(self):
for fs in self.footswitches:
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading