Skip to content
Open
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
67 changes: 67 additions & 0 deletions modalapi/modhandler.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import subprocess
import sys
import yaml
from collections import namedtuple
from collections.abc import Callable
import functools
from functools import cached_property
Expand All @@ -48,7 +49,9 @@
EventKind,
MidiCcEffect,
ParamEffect,
PedalboardEffect,
PresetEffect,
RawMidiCcEffect,
RelayEffect,
TapTempoEffect,
)
Expand Down Expand Up @@ -122,6 +125,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 @@ -222,6 +230,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 +245,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 +525,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 @@ -1149,6 +1176,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
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
34 changes: 33 additions & 1 deletion pistomp/controller_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,14 @@
ContextStack,
ControlClass,
ControlRef,
Effect,
EventKind,
LongpressActionConfig,
MidiCcEffect,
ParamEffect,
PedalboardEffect,
PresetEffect,
RawMidiCcEffect,
RelayEffect,
ShadowState,
TapTempoEffect,
Expand Down Expand Up @@ -262,7 +266,11 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None:
Relay longpress is independent of the short-press action: a relay
footswitch has both a PRESS row (CC toggle or plugin :bypass) and a
LONGPRESS row (RelayEffect). It's added for any footswitch with a
relay_list, regardless of its PRESS binding."""
relay_list, regardless of its PRESS binding.

Mapping-form longpress (`longpress: {midi_CC: 64}` etc., exclusive with
the chord string/list form) rows a single LONGPRESS decl. The relay row
is added first so it keeps precedence if both are present."""
for fs in self._hw.footswitches:
if self._hw.is_external(fs):
continue # owned by _bind_external_controllers
Expand All @@ -282,6 +290,18 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None:
)
)

# Mapping-form longpress: dict config, parsed by Footswitch.
lp = fs.longpress_action
if lp is not None:
pedalboard_layer.add(
BindingDecl(
control=ControlRef(cls=ControlClass.FOOTSWITCH, id=key),
event_kind=EventKind.LONGPRESS,
effects=self._longpress_action_effects(lp, fs),
context=pedalboard_layer.ref,
)
)

if fs.taptempo is not None:
# Taptempo footswitch has two modes: stamp when enabled, CC toggle
# when disabled. Two rows, gated by enabled_when so the resolver
Expand Down Expand Up @@ -331,3 +351,15 @@ def _bind_footswitch_actions(self, pedalboard_layer: ContextLayer) -> None:
context=pedalboard_layer.ref,
)
)

@staticmethod
def _longpress_action_effects(lp: LongpressActionConfig, fs: Footswitch) -> tuple[Effect, ...]:
"""Translate a mapping-form longpress dict into a single-effect tuple.
The schema guarantees exactly one key."""
if "midi_CC" in lp:
return (RawMidiCcEffect(channel=fs.midi_channel, cc=int(lp["midi_CC"])),)
if "preset" in lp:
return (PresetEffect(direction=str(lp["preset"])),)
if "pedalboard" in lp:
return (PedalboardEffect(direction=str(lp["pedalboard"])),)
return ()
13 changes: 13 additions & 0 deletions pistomp/footswitch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

import logging
import sys
from typing import cast

from typing_extensions import override

import common.token as Token
Expand All @@ -23,6 +25,7 @@
import pistomp.gpioswitch as gpioswitch
import pistomp.switchstate as switchstate
from pistomp.input.event import SwitchEvent, SwitchEventKind
from common.contexts import LongpressActionConfig
from common.parameter import BYPASS_SYMBOL


Expand All @@ -43,6 +46,8 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres
self.category = None
self.pixel = pixel
self.longpress_groups = []
# Mapping-form longpress; exclusive with the chord form.
self.longpress_action: LongpressActionConfig | None = None
self.disabled = False
self.taptempo = taptempo

Expand Down Expand Up @@ -159,10 +164,16 @@ def set_lcd_color(self, color):
def set_longpress_groups(self, groups):
if groups is None:
self.longpress_groups = []
self.longpress_action = None
elif isinstance(groups, str):
self.longpress_groups = groups.split()
self.longpress_action = None
elif isinstance(groups, list):
self.longpress_groups = groups
self.longpress_action = None
elif isinstance(groups, dict):
self.longpress_groups = []
self.longpress_action = cast(LongpressActionConfig, groups)

def poll(self):
if self.disabled:
Expand Down Expand Up @@ -203,4 +214,6 @@ def clear_pedalboard_info(self):
self.preset_direction = None
self.preset_callback_arg = None
self.parameter = None
self.longpress_groups = []
self.longpress_action = None
self.clear_relays()
4 changes: 3 additions & 1 deletion pistomp/hardware.py
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,9 @@ def __init_footswitches(self, cfg):
key = format("%d:%d" % (self.midi_channel, fs.midi_CC))
self.controllers[key] = fs # TODO problem if this creates a new element?

# Preset Control
# Clearing midi_CC drops the fs from hw.controllers, so an
# unrelated plugin's MIDI-learned :bypass can't bind onto it;
# dispatch_key falls back to "fs:<id>" and the rows still resolve.
if Token.PRESET in f:
self.__clear_footswitch_midi_cc(fs)
preset_value = f[Token.PRESET]
Expand Down
15 changes: 4 additions & 11 deletions pistomp/lcd320x240.py
Original file line number Diff line number Diff line change
Expand Up @@ -540,23 +540,16 @@ def draw_preset(self, preset_name):

def draw_pedalboard_menu(self, event, widget):
items = []
bank_pbs = util.DICT_GET(self.handler.get_banks(), self.handler.get_bank())

def pedalboard_change(pb):
assert self.handler
self._is_pedalboard_load = True
self.handler.pedalboard_change(pb)

if bank_pbs is None:
# No bank so display all pedalboards as they're stored (alphabetically)
for p in self.pedalboards:
items.append((p.title, pedalboard_change, p))
else:
# Bank is set so show only those in the bank and in the order defined by the bank
for b in bank_pbs:
for p in self.pedalboards: # LAME ugly O(N2) search
if p.title == b:
items.append((p.title, pedalboard_change, p))
# None → no bank; show all pedalboards as stored (alphabetically).
pbs = self.handler.pedalboards_in_bank() or self.pedalboards
for p in pbs:
items.append((p.title, pedalboard_change, p))

self.draw_selection_menu(
items, "Pedalboards", auto_dismiss=True, dismiss_option=True, default_item=self.current.pedalboard.title
Expand Down
Loading
Loading