Skip to content
Open
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 @@ -516,14 +516,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 @@ -390,14 +390,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 @@ -18,7 +18,7 @@
import json

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

Point = tuple[int, int]

Expand All @@ -38,7 +38,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 @@ -56,15 +56,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 @@ -16,6 +16,9 @@
from enum import Enum
import json
import logging
from typing import Optional

from common.parameter import Parameter


class Controller:
Expand All @@ -25,7 +28,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 @@ -34,7 +37,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")


45 changes: 24 additions & 21 deletions pistomp/footswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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 @@ -91,8 +92,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 @@ -139,14 +140,18 @@ def set_midi_CC(self, midi_CC):
def set_midi_channel(self, midi_channel):
self.midi_channel = 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:
# Non-:bypass binding: "on" is the max end (the value an on-press
# sends), so compare against the range midpoint. The bypass
# inversion below would light the LED for an OFF param.
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:
# :bypass (or relay, param is None): engaged when not bypassed.
self.toggled = (value < 1)
self._set_led(self.toggled)
self.refresh_callback(footswitch=self)

Expand Down Expand Up @@ -207,11 +212,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 Down Expand Up @@ -254,18 +255,19 @@ def pressed(self, state):
cc = [self.midi_channel | CONTROL_CHANGE, self.midi_CC, 127 if self.toggled else 0]
logging.debug("Sending CC event: %d" % self.midi_CC)
self.midiout.send_message(cc)
if self.drives_display:
self._set_led(self.toggled)

if self.drives_display:
self.refresh_callback(footswitch=self)
self._set_led(self.toggled)
# LCD / plugin state is updated only when the echo arrives, so unbound
# footswitches (tap-tempo / relay / preset) still refresh immediately.
if self.parameter is None:
self.refresh_callback(footswitch=self)
return

def set_display_label(self, label):
self.display_label = 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 @@ -278,6 +280,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.clear_relays()
9 changes: 3 additions & 6 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,22 +483,19 @@ 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):
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 All @@ -510,7 +507,7 @@ def draw_unbound_footswitches(self):
y = 0
x = self.get_footswitch_pitch() * slot
p = FootswitchWidget(Box.xywh(x, y, self.plugin_width, self.footswitch_height), self.small_font,
label, None, True, parent=self.footswitch_panel, object=fs)
label, None, not fs.toggled, parent=self.footswitch_panel, object=fs)
self.w_footswitches.append(p)
self.footswitch_panel.add_widget(p)
self.footswitch_panel.refresh()
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