diff --git a/common/contexts.py b/common/contexts.py
index 547a3c3cf..185f4995d 100644
--- a/common/contexts.py
+++ b/common/contexts.py
@@ -98,7 +98,7 @@ class Effect:
class ParamEffect(Effect):
plugin: object # PluginRef, resolved at pedalboard load
symbol: Union[Symbol, type[SelectionSymbol]]
- commit: bool = True # WebSocket send_parameter on fire
+ commit: bool = True # Declared: WebSocket send on fire. Dispatch does not read it. A no-op today.
mirror: bool = True # reconcile from inbound param_set echo
@@ -212,6 +212,12 @@ def add(self, decl: BindingDecl) -> None:
key = (decl.control.cls, decl.event_kind)
self.rows.setdefault(key, []).append(decl)
+ def remove(self, should_drop: Callable[[BindingDecl], bool]) -> None:
+ """Drop every row for which *should_drop* is true, across all buckets —
+ the mutation counterpart to add(), so callers never touch `rows`."""
+ for key, rows in list(self.rows.items()):
+ self.rows[key] = [d for d in rows if not should_drop(d)]
+
# Per-class chain: which ContextKinds are consulted, top (highest precedence)
# to bottom, for a given ControlClass. NAV is intentionally absent — it is an
diff --git a/common/loop_progress.py b/common/loop_progress.py
new file mode 100644
index 000000000..5b39b2e54
--- /dev/null
+++ b/common/loop_progress.py
@@ -0,0 +1,43 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""How far a looping plugin is through its loop, as the footswitch strip draws it.
+
+Shared between the renderer that derives it (`modalapi.led_render`) and the
+widget that paints it (`uilib.footswitch`), which sit on opposite sides of the
+module DAG.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from enum import Enum, auto
+
+
+class LoopFill(Enum):
+ FILL = auto() # determinate: `position` of the perimeter is behind us
+ STATIC = auto() # the loop exists but isn't moving
+ CHASE = auto() # length unknown: a head sweeping at `position`
+
+
+@dataclass(frozen=True)
+class LoopProgress:
+ mode: LoopFill
+ color: tuple[int, int, int]
+ segments: int # bars in the loop; 0 when the length isn't known yet
+ position: float = 0.0 # turns, [0, 1)
+ pulse: float = 1.0 # brightness of the lit part; the beat envelope, 1.0 when free-running
diff --git a/common/parameter.py b/common/parameter.py
index 531a1497e..1b32052a1 100644
--- a/common/parameter.py
+++ b/common/parameter.py
@@ -34,6 +34,7 @@
TTL_SCALEPOINTS = "scalePoints"
TTL_TAPTEMPO = "tapTempo"
TTL_TOGGLED = "toggled"
+TTL_TRIGGER = "trigger"
# Identifies a Parameter: the key of plugin.parameters, ParamEffect.symbol,
# edit_symbol(). Usually an LV2 port symbol (":bypass", "gain"); also an ALSA
@@ -110,6 +111,14 @@ def is_hidden_port(plugin_info: PortInfo) -> bool:
return plugin_info.get("designation", "") in HIDDEN_DESIGNATIONS
+def _has_property(properties: list[str], name: str) -> bool:
+ """Match MOD-UI's short property names and raw LV2 URIs."""
+ return any(
+ property_name == name or property_name.rsplit("#", 1)[-1].rsplit("/", 1)[-1] == name
+ for property_name in properties
+ )
+
+
class Type(Enum):
DEFAULT = 0 # No explicitly defined type (eg. linear float)
ENUMERATION = 1
@@ -117,6 +126,7 @@ class Type(Enum):
LOGARITHMIC = 3
TAPTEMPO = 4
TOGGLED = 5
+ TRIGGER = 6 # pprops:trigger — edge-triggered, self-clearing (momentary)
class Parameter:
@@ -171,20 +181,28 @@ def __init__(
properties = plugin_info.get("properties") or []
if len(properties) > 0:
- if TTL_LOGARITHMIC in properties:
+ if _has_property(properties, TTL_LOGARITHMIC):
self.is_logarithmic = True
- if TTL_ENUMERATION in properties:
+ if _has_property(properties, TTL_TRIGGER):
+ self.type = Type.TRIGGER
+ elif _has_property(properties, TTL_ENUMERATION):
self.enum_values = plugin_info.get("scalePoints") or []
self.type = Type.ENUMERATION
- elif TTL_INTEGER in properties:
+ elif _has_property(properties, TTL_INTEGER):
self.type = Type.INTEGER
- elif TTL_LOGARITHMIC in properties:
+ elif _has_property(properties, TTL_LOGARITHMIC):
self.type = Type.LOGARITHMIC
- elif TTL_TAPTEMPO in properties:
+ elif _has_property(properties, TTL_TAPTEMPO):
self.type = Type.TAPTEMPO
- elif TTL_TOGGLED in properties:
+ elif _has_property(properties, TTL_TOGGLED):
self.type = Type.TOGGLED
+ @property
+ def is_momentary(self) -> bool:
+ """True for edge-triggered, self-clearing ports (pprops:trigger) —
+ these need a one-shot 127 press rather than an absolute 127/0 toggle."""
+ return self.type == Type.TRIGGER
+
@property
def value(self) -> float:
return self._value
@@ -216,6 +234,16 @@ def commit(self, value: float, sink: ParamSink | None) -> None:
return
self._notify_settled()
+ def pulse(self, sink: ParamSink | None) -> None:
+ """A self-clearing trigger edge (pprops:trigger): drive to the "on" edge,
+ publish it once through *sink*, then clear to rest — persists no value
+ and never reverts, so each press is a fresh rising edge."""
+ self._set(self.maximum)
+ if sink is not None:
+ sink(self)
+ self._set(self.minimum)
+ self._notify_settled()
+
def _set(self, value: float) -> None:
if value == self._value:
return
diff --git a/modalapi/led_render.py b/modalapi/led_render.py
new file mode 100644
index 000000000..63ae3f4a8
--- /dev/null
+++ b/modalapi/led_render.py
@@ -0,0 +1,101 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+"""Generic, data-driven footswitch-LED rendering.
+
+Pure function of (LedSpec, plugin.output_values) -> (color, style). No
+footswitch, beat, or plugin-instance coupling — the handler applies the binary
+metronome state uniformly to the physical LED and loop perimeter.
+"""
+
+from __future__ import annotations
+
+from enum import Enum, auto
+from typing import TYPE_CHECKING
+
+from common.loop_progress import LoopFill, LoopProgress
+
+if TYPE_CHECKING:
+ from modalapi.plugin_customization import LedSpec
+
+
+class LedDisplayStyle(Enum):
+ SOLID = auto()
+ METRONOME = auto()
+
+
+def render_led_spec(
+ spec: LedSpec, output_values: dict[str, float]
+) -> tuple[tuple[int, int, int] | None, LedDisplayStyle]:
+ state = int(output_values.get(spec.state_symbol, 0))
+ if state in spec.off_states:
+ return None, LedDisplayStyle.SOLID
+ base = spec.colors.get(state)
+ if base is None:
+ return None, LedDisplayStyle.SOLID
+ if spec.downbeat_symbol is not None and int(output_values.get(spec.downbeat_symbol, -1)) == 0:
+ base = (
+ min(255, base[0] + spec.downbeat_tint),
+ min(255, base[1] + spec.downbeat_tint),
+ min(255, base[2] + spec.downbeat_tint),
+ )
+ style = LedDisplayStyle.METRONOME if (spec.pulse and state not in spec.steady_states) else LedDisplayStyle.SOLID
+ return base, style
+
+
+def metronome_brightness(is_flashing: bool) -> float:
+ """Binary brightness shared by physical LEDs and the loop perimeter."""
+ return 1.0 if is_flashing else 0.0
+
+
+def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None:
+ """Short display name for the current state, or None when the plugin
+ declares no `labels`. Same lookup as `render_led_spec`, for the LCD."""
+ if spec.labels is None:
+ return None
+ return spec.labels.get(int(output_values.get(spec.state_symbol, 0)))
+
+
+def loop_progress(
+ spec: LedSpec,
+ output_values: dict[str, float],
+ bar_phase: float,
+ beat_brightness: float = 1.0,
+) -> LoopProgress | None:
+ """Where the plugin is through its loop, or None if it has no loop to be
+ through. `bar_phase` interpolates within the current bar — the plugin only
+ publishes a bar index, and a per-sample position port would be a monitored
+ output changing every process cycle. `beat_brightness` is 1.0 during the
+ fixed metronome window and 0.0 otherwise, applied only to pulsing states."""
+ if spec.bars_symbol is None or spec.downbeat_symbol is None:
+ return None
+ color, style = render_led_spec(spec, output_values)
+ if color is None:
+ return None
+ pulse = beat_brightness if style is LedDisplayStyle.METRONOME else 1.0
+
+ state = int(output_values.get(spec.state_symbol, 0))
+ bars = int(output_values.get(spec.bars_symbol, 0))
+ measure = int(output_values.get(spec.downbeat_symbol, 0))
+
+ # Past the length it declared (an overdub that outgrew the head loop) is
+ # the same situation as a take still recording: a position, no denominator.
+ if state in spec.chase_states or (bars > 0 and measure >= bars):
+ return LoopProgress(LoopFill.CHASE, color, 0, bar_phase, pulse)
+ if bars <= 0:
+ return None
+ if state in spec.steady_states:
+ return LoopProgress(LoopFill.STATIC, color, bars)
+ return LoopProgress(LoopFill.FILL, color, bars, (measure + bar_phase) / bars, pulse)
diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py
index 979ba0bac..3cea9faf9 100755
--- a/modalapi/modhandler.py
+++ b/modalapi/modhandler.py
@@ -59,6 +59,7 @@
RelayEffect,
TapTempoEffect,
)
+from common.color import accent_color_for
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol
from common.param_source import ParamSink
from common.parameter_steps import ParameterSteps, effective_multiplier
@@ -75,6 +76,8 @@
from plugins.customization import lookup as plugin_lookup
from plugins.customization import patch_extra_data
import modalapi.external_midi as ExternalMidi
+from common.loop_progress import LoopProgress
+from modalapi.led_render import LedDisplayStyle, loop_progress, metronome_brightness, render_led_spec
from modalapi.ethernet import EthernetManager
from modalapi.jack_mute import JackMute
from pistomp.lcd320x240 import Lcd
@@ -86,9 +89,11 @@
parse_message,
LoadingEndMessage,
LoadingStartMessage,
+ OutputSetMessage,
PedalSnapshotMessage,
PluginBypassMessage,
TransportMessage,
+ BeatSyncMessage,
AddPluginMessage,
PatchSetMessage,
RemovePluginMessage,
@@ -111,6 +116,7 @@
)
from pistomp.footswitch import Footswitch
from pistomp.footswitch_chords import FootswitchChords
+from pistomp.beatsync import FLASH_US, BeatGrid, TickState
from pistomp.input.event import (
AnalogEvent,
ControllerEvent,
@@ -129,12 +135,29 @@
STARTUP_REST_BACKOFF_S = (0.25, 0.25, 0.5, 1.0, 2.0)
-def _remove_binding_row(layer: ContextLayer, binding_id: str) -> None:
+def _remove_binding_row(
+ layer: ContextLayer,
+ binding_id: str,
+ instance_id: str | None = None,
+ symbol: Symbol | None = None,
+) -> None:
# Drop any PEDALBOARD-layer row whose control.id matches a learned binding
# that's being replaced. Scans all event_kind buckets since a re-learn could
- # cross controller classes (footswitch ↔ encoder).
- for (cls, event_kind), rows in list(layer.rows.items()):
- layer.rows[(cls, event_kind)] = [d for d in rows if d.control.id != binding_id]
+ # cross controller classes (footswitch ↔ encoder). When the param being
+ # rebound is known, keep rows at the same CC that target a *different*
+ # plugin/param, so two instances sharing one CC never evict each other.
+ def _keep(d: BindingDecl) -> bool:
+ if d.control.id != binding_id:
+ return True
+ if instance_id is not None and symbol is not None:
+ eff = d.effects[0] if d.effects else None
+ if isinstance(eff, ParamEffect) and (
+ getattr(eff.plugin, "instance_id", None) != instance_id or eff.symbol != symbol
+ ):
+ return True
+ return False
+
+ layer.remove(lambda d: not _keep(d))
class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])):
@@ -142,6 +165,14 @@ class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])):
send next. mod-ui's echo reconciles the learned plugin."""
+_METRONOME_DOWNBEAT_RGB = (255, 255, 255)
+_METRONOME_BEAT_RGB = (180, 180, 180)
+
+
+def _now_us() -> int:
+ return int(time.clock_gettime(time.CLOCK_MONOTONIC) * 1_000_000)
+
+
class Modhandler(Handler):
__single = None
@@ -258,6 +289,9 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data")
# Footswitch longpress/chord resolver (rebuilt on pedalboard change)
self.chord_helper = FootswitchChords()
+ self.beat_grid = BeatGrid()
+ self._last_beat: TickState | None = None
+ self._taptempo_fs_cache: Footswitch | None = None
# First raw-CC longpress sends 127; alternates thereafter.
self._longpress_cc_state: dict[LongpressCcKey, bool] = {}
@@ -288,9 +322,7 @@ def _rest_get_with_retry(self, url: str) -> Response | None:
resp = self._rest_get(url)
if resp is not None and resp.status_code == 200:
return resp
- logging.info(
- "mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay
- )
+ logging.info("mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay)
time.sleep(delay)
return self._rest_get(url)
@@ -454,9 +486,7 @@ def _handle_switch(self, event: SwitchEvent) -> bool:
# ControllerManager._bind_encoder_longpress.
if event.kind == SwitchEventKind.LONGPRESS and controller.midi_CC is not None:
key = f"{controller.midi_channel}:{controller.midi_CC}"
- winner = self.effective_table.resolve(
- ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS
- )
+ winner = self.effective_table.resolve(ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS)
if winner is not None:
self._fire_row(winner, event)
return True
@@ -471,9 +501,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f
Handler._handle_footswitch imperative if-chain."""
if kind == SwitchEventKind.LONGPRESS:
key = fs.dispatch_key
- winner = self.effective_table.resolve(
- ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS
- )
+ winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS)
if winner is not None:
self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp))
return True
@@ -483,9 +511,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f
# Short press
key = fs.dispatch_key
- winner = self.effective_table.resolve(
- ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS
- )
+ winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS)
if winner is not None:
self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp))
return True
@@ -526,21 +552,30 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool:
if controller.parameter is not None:
controller.parameter.preview(controller.value_for(controller.toggled))
self.update_lcd_fs(footswitch=controller)
- case ParamEffect():
- # Footswitch PRESS with a bound plugin param. "on" polarity
- # differs by param: :bypass "on" is not-bypassed (0), a plain
- # toggle "on" is the max end — value_for encodes both, the
- # inverse of Footswitch.set_value. The MIDI CC carries the
- # change to mod-host.
+ case ParamEffect(plugin=eff_plugin, symbol=eff_symbol):
+ # Resolve the param from the row's own effect, not
+ # fs.parameter, so two instances sharing a CC stay distinct.
if fs is not None:
- new_toggled = not fs.toggled
- fs.toggled = new_toggled
- fs.set_led(new_toggled)
- if fs.midi_CC is not None:
- self._emit_midi(fs, 127 if new_toggled else 0)
- if fs.parameter is not None:
- fs.parameter.preview(fs.value_for(new_toggled))
- self.update_lcd_fs(footswitch=fs)
+ params = getattr(eff_plugin, "parameters", None)
+ param = params.get(eff_symbol) if params else None
+ if param is not None and param.is_momentary:
+ # pprops:trigger (advance): one-shot edge, not a
+ # toggle — the plugin self-clears the port.
+ param.pulse(self._sink_for(param, fs))
+ self.update_lcd_fs(footswitch=fs)
+ else:
+ # Absolute toggle (:bypass, plain toggled params): "on"
+ # polarity differs by param — value_for encodes both,
+ # the inverse of Footswitch.set_value. The MIDI CC
+ # carries the change to mod-host.
+ new_toggled = not fs.toggled
+ fs.toggled = new_toggled
+ fs.set_led(new_toggled)
+ if fs.midi_CC is not None:
+ self._emit_midi(fs, 127 if new_toggled else 0)
+ if param is not None:
+ param.preview(fs.value_for(new_toggled))
+ self.update_lcd_fs(footswitch=fs)
case RelayEffect():
if fs is not None:
new_toggled = not fs.toggled
@@ -549,9 +584,24 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool:
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)
+ param = self._param_bound_to_cc(ch, cc)
+ sink = functools.partial(self._publish_raw_cc, ch, cc)
+ if param is not None and param.is_momentary:
+ # Trigger target (loopjefe reset): one-shot edge on the
+ # param's own bound CC. Resolved at fire time, so a live
+ # re-learn needs no longpress-row patch.
+ param.pulse(sink)
+ elif param is not None:
+ # Loaded toggle target: flip through the reactive layer.
+ lo = param.minimum if param.minimum is not None else 0.0
+ hi = param.maximum if param.maximum is not None else 1.0
+ edge = lo if param.value >= (lo + hi) / 2 else hi
+ param.commit(edge, sink)
+ else:
+ # Orphan CC (no loaded param): local 127/0 toggle.
+ 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()
@@ -564,6 +614,29 @@ def _emit_raw_cc(self, channel: int, cc: int, value: int) -> None:
controller.midi_CC guard; virtual out only."""
self.hardware.midiout.send_message([channel | CONTROL_CHANGE, cc, int(value)])
+ def _param_bound_to_cc(self, channel: int, cc: int) -> Parameter | None:
+ """The loaded plugin parameter MIDI-bound to (channel, cc), or None for
+ an orphan CC. Resolved at fire time so a live re-learn is reflected with
+ no longpress-row patch — the CC is a free reference, not a controller."""
+ if self._current is None:
+ return None
+ binding = f"{channel}:{cc}"
+ for plugin in self.current.pedalboard.plugins:
+ if plugin.parameters is None:
+ continue
+ for param in plugin.parameters.values():
+ if param.binding == binding:
+ return param
+ return None
+
+ def _publish_raw_cc(self, channel: int, cc: int, param: Parameter) -> bool:
+ """Emit a resolved param's binary CC on its own (channel, cc): the "on"
+ edge (max) sends 127, rest 0, with no owning controller. pulse and commit
+ ride this so a longpress reaches mod-host on the param's bound CC."""
+ hi = param.maximum if param.maximum is not None else 1.0
+ self._emit_raw_cc(channel, cc, 127 if param.value >= hi else 0)
+ return True
+
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:
@@ -596,11 +669,133 @@ def poll_controls(self):
if self.hardware:
self.hardware.poll_controls()
self.chord_helper.poll()
+ # Drive footswitch LEDs in the same 10ms tick as the press so there's
+ # no latency between a state change and the LED reflecting it. Both
+ # fs.pixel and fs.led are written here — the single source of truth.
+ self._drive_footswitch_leds()
def poll_indicators(self):
if self.hardware:
self.hardware.poll_indicators()
+ def _taptempo_footswitch(self):
+ if self._taptempo_fs_cache is None and self.hardware is not None:
+ for fs in self.hardware.footswitches:
+ if fs.taptempo is not None:
+ self._taptempo_fs_cache = fs
+ break
+ return self._taptempo_fs_cache
+
+ def _drive_footswitch_leds(self, beat: TickState | None = None) -> None:
+ """Single per-tick LED driver: for each footswitch, get a (color, style)
+ frame from whichever renderer applies, then write it through the one
+ writer below. The taptempo footswitch is just another renderer — not a
+ special-cased branch — so ownership of "the pulse" lives in one place:
+ the brightness envelope in `_write_led`."""
+ if self.hardware is None:
+ return
+ if beat is None:
+ beat = self.beat_grid.tick(_now_us())
+ # The LCD reads the phase from here rather than ticking the grid
+ # itself; a second tick() would swallow the beat-crossing edge.
+ self._last_beat = beat
+ taptempo_fs = self._taptempo_footswitch()
+ for fs in self.hardware.footswitches:
+ if fs is taptempo_fs:
+ color, style = self._render_taptempo(fs, beat)
+ else:
+ color, style = self._render_footswitch(fs, beat)
+ self._write_led(fs, color, style, beat)
+
+ def _render_taptempo(self, fs: Footswitch, beat: TickState) -> tuple[tuple[int, int, int] | None, LedDisplayStyle]:
+ """Built-in renderer for the taptempo footswitch: while tap tempo mode is
+ enabled, flashes from whichever beat source is active — transport-anchored
+ beat grid, or taptempo.anchor + bpm blink when unanchored — else falls
+ back to the default per-footswitch renderer."""
+ if fs.taptempo is None or not fs.taptempo.is_enabled():
+ return self._render_footswitch(fs, beat)
+ if beat.is_anchored:
+ if beat.is_flashing:
+ return (_METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB), LedDisplayStyle.SOLID
+ return None, LedDisplayStyle.SOLID
+ # Unanchored: with a bpm, blink from the taptempo
+ # phase (on for the fixed metronome window of each beat period).
+ if fs.taptempo.get_bpm() > 0:
+ now_s = _now_us() / 1_000_000.0
+ period = 60.0 / fs.taptempo.get_bpm()
+ elapsed = now_s - fs.taptempo.anchor
+ phase_in_beat = elapsed % period
+ if phase_in_beat < FLASH_US / 1_000_000.0:
+ return _METRONOME_BEAT_RGB, LedDisplayStyle.SOLID
+ return None, LedDisplayStyle.SOLID
+ # No bpm yet: fall through to the default renderer.
+ return self._render_footswitch(fs, beat)
+
+ def _render_footswitch(
+ self,
+ fs: Footswitch,
+ beat: TickState, # noqa: ARG002 - kept for renderer signature symmetry
+ ) -> tuple[tuple[int, int, int] | None, LedDisplayStyle]:
+ """Default per-footswitch renderer: a plugin's declarative LedSpec (read
+ from its generically-mirrored output_values) if bound and available,
+ else the built-in toggle + category color."""
+ plugin = self._bound_plugin(fs)
+ if plugin is not None and plugin.customization.led_spec is not None:
+ return render_led_spec(plugin.customization.led_spec, plugin.output_values)
+ if not fs.toggled:
+ return None, LedDisplayStyle.SOLID
+ color = accent_color_for(fs.category) if fs.category is not None else (255, 255, 255)
+ return color, LedDisplayStyle.SOLID
+
+ def footswitch_loop_progress(self, fs: Footswitch) -> LoopProgress | None:
+ """Loop position for a switch bound to a plugin that publishes one."""
+ plugin = self._bound_plugin(fs)
+ if plugin is None:
+ return None
+ spec = plugin.customization.led_spec
+ if spec is None:
+ return None
+ beat = self._last_beat
+ if beat is None or not beat.is_anchored:
+ return loop_progress(spec, plugin.output_values, 0.0)
+ return loop_progress(
+ spec,
+ plugin.output_values,
+ beat.bar_phase,
+ metronome_brightness(beat.is_flashing),
+ )
+
+ def _bound_plugin(self, fs: Footswitch):
+ if fs.parameter is None or self._current is None:
+ return None
+ for plugin in self.current.pedalboard.plugins:
+ if plugin.instance_id == fs.parameter.instance_id:
+ return plugin
+ return None
+
+ @staticmethod
+ def _write_led(
+ fs: Footswitch,
+ color: tuple[int, int, int] | None,
+ style: LedDisplayStyle,
+ beat: TickState,
+ ) -> None:
+ """Write one rendered frame to both physical LED outputs."""
+ if color is not None and style == LedDisplayStyle.METRONOME and beat.is_anchored:
+ if not beat.is_flashing:
+ color = None
+ if color is None:
+ if fs.pixel is not None:
+ fs.pixel.set_enable(False)
+ if fs.led is not None:
+ fs.led.off()
+ return
+ if fs.pixel is not None:
+ fs.pixel.set_color(color)
+ fs.pixel.set_enable(True)
+ if fs.led is not None:
+ fs.led.on()
+
def poll_wifi(self):
self.wifi_manager.poll()
if self._lcd is not None and self.lcd.wifi_menu is not None:
@@ -892,6 +1087,21 @@ def _handle_ws_message(self, msg: WebSocketMessage):
self.lcd.update_sync_mode(new_sync)
if rolling_changed:
self.lcd.update_audio_midi_tile()
+ if not msg.rolling:
+ self.beat_grid.clear()
+
+ elif isinstance(msg, BeatSyncMessage):
+ self.beat_grid.on_anchor(msg)
+
+ elif isinstance(msg, OutputSetMessage):
+ if self._current is not None:
+ for plugin in self.current.pedalboard.plugins:
+ if plugin.instance_id == msg.instance:
+ changed = plugin.output_values.get(msg.symbol) != msg.value
+ plugin.set_output_value(msg.symbol, msg.value)
+ if changed:
+ self._repaint_state_switches(plugin, msg.symbol)
+ break
elif isinstance(msg, ParamSetMessage):
# Mirror mod-ui's live value: refresh the cache (so a later edit opens
@@ -1207,6 +1417,23 @@ def bind_current_pedalboard(self):
# The pedalboard data has already been loaded, but this will overlay
# any real time settings
self._controller_manager.bind(self.current)
+ self._update_interesting_outputs()
+
+ def _update_interesting_outputs(self) -> None:
+ """Recompute the WS output_set subscription set from the pedalboard's
+ plugins (their own declared LedSpec outputs) — the plugin is the
+ natural owner of its output ports, not whichever footswitch happens to
+ be bound to it. Computed once at pedalboard load; a footswitch binding
+ change afterward can't add or remove monitored outputs since those are
+ fixed per plugin instance."""
+ if self._current is None:
+ self.ws_bridge.set_interesting_outputs(frozenset())
+ return
+ keys: set[str] = set()
+ for plugin in self.current.pedalboard.plugins:
+ for sym in plugin.monitored_output_symbols:
+ keys.add(f"{plugin.instance_id}/{sym}")
+ self.ws_bridge.set_interesting_outputs(frozenset(keys))
def _sink_for(self, param: Parameter, controller: Controller | None = None) -> ParamSink | None:
"""The upstream channel a param's commit rides, by provenance. None is
@@ -1226,6 +1453,10 @@ def _sink_for(self, param: Parameter, controller: Controller | None = None) -> P
enc = enc if isinstance(enc, EncoderController) else None
if enc is not None and enc.midi_CC is not None:
return functools.partial(self._publish_cc, enc)
+ if isinstance(controller, Footswitch) and controller.midi_CC is not None:
+ # A footswitch rides its own CC (pulse for a trigger, toggle
+ # otherwise), not the param_set an unheld param takes.
+ return functools.partial(self._publish_fs_cc, controller)
if param.instance_id in (ExternalMidi.EXTERNAL_INSTANCE_ID, Pedalboard.TRANSPORT_INSTANCE_ID):
return None
return self._publish_plugin_param
@@ -1235,7 +1466,7 @@ def _publish_bpm(self, param: Parameter) -> bool:
return self.set_mod_tap_tempo(param.value)
def _publish_audio(self, param: Parameter) -> bool:
- """ A local ALSA write. No remote echo, so the send always lands."""
+ """A local ALSA write. No remote echo, so the send always lands."""
self.audio_parameter_commit(param.symbol, param.value)
return True
@@ -1244,6 +1475,16 @@ def _publish_cc(self, controller: EncoderController, param: Parameter) -> bool:
self._emit_midi(controller, controller.to_midi(param.value))
return True
+ def _publish_fs_cc(self, fs: Footswitch, param: Parameter) -> bool:
+ """Emit a footswitch-bound param as its binary CC: the "on" edge (max)
+ sends 127, rest 0. A trigger pulse holds max for the send, so it fires
+ one 127 then self-clears."""
+ if fs.midi_CC is None:
+ return False
+ hi = param.maximum if param.maximum is not None else 1.0
+ self._emit_midi(fs, 127 if param.value >= hi else 0)
+ return True
+
def _publish_plugin_param(self, param: Parameter) -> bool:
if self._is_pedalboard_loading or self.ws_bridge is None or param.instance_id is None:
return False
@@ -1261,11 +1502,19 @@ def _add_learned_binding_row(
) -> None:
layer = self._controller_manager.effective_table.layers[0]
if old_binding is not None:
- _remove_binding_row(layer, old_binding)
+ _remove_binding_row(layer, old_binding, plugin.instance_id, param.symbol)
if controller is None:
return
if isinstance(controller, Footswitch):
cls, event_kind = ControlClass.FOOTSWITCH, EventKind.PRESS
+ # Drop the bare CC-toggle PRESS row _bind_footswitch_actions added
+ # at this key; inserted first, it would shadow this ParamEffect.
+ assert param.binding is not None
+ layer.remove(
+ lambda d: d.control.id == param.binding
+ and bool(d.effects)
+ and isinstance(d.effects[0], MidiCcEffect)
+ )
else:
cls, event_kind = ControlClass.ANALOG, EventKind.ROTATE
assert param.binding is not None
@@ -1438,6 +1687,17 @@ def toggle_plugin_bypass(self, plugin):
if not self._is_pedalboard_loading:
self.ws_bridge.send_parameter(plugin.instance_id, BYPASS_SYMBOL, value)
+ def _repaint_state_switches(self, plugin, symbol: str) -> None:
+ """The plugin moved its LedSpec state; the switches bound to it render
+ that word, so they need repainting. Only the state port qualifies —
+ the downbeat port ticks every bar and only the LED driver reads it."""
+ spec = plugin.customization.led_spec
+ if spec is None or symbol != spec.state_symbol:
+ return
+ for controller in plugin.controllers:
+ if isinstance(controller, Footswitch):
+ self.update_lcd_fs(footswitch=controller)
+
def update_lcd_fs(self, footswitch=None, bypass_change=False):
self.lcd.update_footswitch(footswitch)
@@ -1534,6 +1794,7 @@ def maybe_show_welcome(self):
if self.settings.get_setting(Token.WELCOME_SEEN):
return
from ui.welcome import WelcomePanel
+
self.lcd.pstack.push_panel(WelcomePanel(self))
def get_software_version(self) -> str:
diff --git a/modalapi/plugin.py b/modalapi/plugin.py
index 5c55f587e..190198b56 100755
--- a/modalapi/plugin.py
+++ b/modalapi/plugin.py
@@ -65,6 +65,10 @@ def __init__(
self.category: str | None = category
self.uri: str | None = uri
self.pedalboard_snapshot: dict[Symbol, float] = {}
+ # Generic mirror of this plugin's subscribed lv2:OutputPort values (see
+ # `monitored_output_symbols`). Populated from WS `output_set` messages;
+ # consumed by the LED driver's LedSpec lookups. No footswitch involved.
+ self.output_values: dict[str, float] = {}
c: PluginCustomization = customization or PluginCustomization()
if extra_data is not None:
c = replace(c, extra_data=extra_data)
@@ -74,6 +78,24 @@ def __init__(
def extra_data(self) -> PluginExtraData | None:
return self.customization.extra_data
+ @property
+ def monitored_output_symbols(self) -> tuple[str, ...]:
+ """Output-port symbols this plugin wants mirrored via WS output_set,
+ derived from its LedSpec (if any). Generic — no footswitch involved."""
+ spec = self.customization.led_spec
+ if spec is None:
+ return ()
+ symbols = [spec.state_symbol]
+ if spec.downbeat_symbol is not None:
+ symbols.append(spec.downbeat_symbol)
+ if spec.bars_symbol is not None:
+ symbols.append(spec.bars_symbol)
+ return tuple(symbols)
+
+ def set_output_value(self, symbol: str, value: float) -> None:
+ """Cache a subscribed lv2:OutputPort value (from WS output_set)."""
+ self.output_values[symbol] = value
+
@property
def display_name(self) -> str:
c = self.customization
diff --git a/modalapi/plugin_customization.py b/modalapi/plugin_customization.py
index e7640b3a7..90a928dc0 100644
--- a/modalapi/plugin_customization.py
+++ b/modalapi/plugin_customization.py
@@ -47,6 +47,40 @@ def extra_data_as(plugin: Plugin, kind: type[_TExtra]) -> _TExtra | None:
return data if isinstance(data, kind) else None
+@dataclass(frozen=True)
+class LedSpec:
+ """Declarative footswitch-LED rendering for a plugin, keyed off its own
+ (generically-mirrored) output ports. Interpreted by the handler's generic
+ LED driver — no per-plugin imperative code required.
+
+ state_symbol: the output port whose integer value selects `colors`.
+ downbeat_symbol: an optional second output port (e.g. loopjefe's
+ `measure_number`) whose value == 0 means "this is the loop's own
+ downbeat" — brightens the color by `downbeat_tint` per channel.
+ off_states / steady_states: state values that render as off, or as a
+ steady (non-pulsing) color even when `pulse` is True.
+ bars_symbol: an output port carrying the loop's length in bars — the
+ denominator `downbeat_symbol` counts against, so the pair yields a
+ position around the footswitch's progress border. 0 means unknown.
+ chase_states: state values that have no length to be a fraction of (a
+ take still being recorded) but should still show motion.
+ labels: state values to short display names for the LCD. The port is an
+ lv2:OutputPort, so its scalePoints never reach us as a Parameter —
+ they have to be declared here alongside the colors.
+ """
+
+ state_symbol: str
+ colors: dict[int, tuple[int, int, int]]
+ labels: dict[int, str] | None = None
+ pulse: bool = False
+ off_states: frozenset[int] = frozenset()
+ steady_states: frozenset[int] = frozenset()
+ downbeat_symbol: str | None = None
+ downbeat_tint: int = 60
+ bars_symbol: str | None = None
+ chase_states: frozenset[int] = frozenset()
+
+
@dataclass(frozen=True)
class PinnedParam:
"""One arc-ring slot in a parameter window.
@@ -71,6 +105,9 @@ class PluginCustomization:
tile_active_color: tuple[int, int, int] | None = None
tile_border: RectBorder | None = None
extra_data: PluginExtraData | None = None
+ led_spec: LedSpec | None = None
+ # Replace the plugin name text with the LoopIconGlyph racetrack icon.
+ loop_icon: bool = False
# Per-symbol edit-math classification, supplementing the LV2 port's
# Symbols absent here are ParamRole.GENERIC.
diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py
index 45de1c348..7026bad11 100644
--- a/modalapi/websocket_bridge.py
+++ b/modalapi/websocket_bridge.py
@@ -31,6 +31,7 @@
from typing import Optional
import websockets
+import websockets.exceptions # lazy __getattr__ aliases the API, not the submodules
import uvloop
from common.parameter import Symbol
@@ -48,7 +49,11 @@ class WebSocketWorker:
"""
def __init__(
- self, ws_url: str, backpressure_threshold: int, command_queue: queue.Queue, received_queue: queue.Queue
+ self,
+ ws_url: str,
+ backpressure_threshold: int,
+ command_queue: queue.Queue,
+ received_queue: queue.Queue,
):
self.ws_url = ws_url
self.backpressure_threshold = backpressure_threshold
@@ -58,6 +63,15 @@ def __init__(
self.ws = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._stop_event: asyncio.Event = asyncio.Event()
+ # Atomically-swappable set of "instance/symbol" keys whose output_set
+ # frames survive the prefix drop. Owned by the worker so it doesn't
+ # need a back-reference to the bridge. Swapped from the main thread
+ # via set_interesting_outputs; read here on the worker thread. The
+ # frozenset ref-swap is atomic under the GIL (CPython only).
+ self._interesting: frozenset[str] = frozenset()
+ # Latest unsubscribed output_set per "instance/symbol", replayed when a
+ # subscription for it arrives. Bounded by the port count, not the rate.
+ self._latest_outputs: dict[str, str] = {}
self._wakeup: asyncio.Event = asyncio.Event()
# Metrics
@@ -236,7 +250,22 @@ async def _receive_messages(self, ws):
await ws.send(message)
continue
elif message.startswith("output_set "):
- continue # audio-meter flood; nothing consumes it, drop before it floods the queue
+ # Keep only if a footswitch behavior subscribed to this output.
+ parts = message.split(" ", 3)
+ if len(parts) >= 3:
+ inst = parts[1].removeprefix("/graph/")
+ key = f"{inst}/{parts[2]}"
+ if key in self._interesting:
+ self.received_queue.put(message)
+ self.messages_received += 1
+ logging.debug(f"Received subscribed output_set: {message[:100]}")
+ else:
+ # mod-ui dumps every monitored port on connect, before
+ # the board binds and the subscriptions are known. Hold
+ # the latest value per port so the first paint isn't
+ # stale until the plugin next moves.
+ self._latest_outputs[key] = message
+ continue
self.received_queue.put(message)
self.messages_received += 1
logging.debug(f"Received message from server: {message[:100]}")
@@ -245,6 +274,20 @@ async def _receive_messages(self, ws):
except Exception as e:
logging.error(f"Error receiving message: {e}")
+ def set_interesting_outputs(self, keys: frozenset[str]) -> None:
+ """Atomically swap the set of 'instance/symbol' keys whose output_set
+ frames survive the prefix drop. Called from the main thread on
+ pedalboard load/rebind. Thread-safe under the GIL (frozenset ref swap).
+
+ Set before the replay, so a value arriving mid-swap takes the queue path
+ rather than landing in a dict nobody drains again."""
+ self._interesting = keys
+ for key in keys:
+ message = self._latest_outputs.pop(key, None)
+ if message is not None:
+ self.received_queue.put(message)
+ self.messages_received += 1
+
def _get_write_buffer_size(self, ws) -> int:
"""Return bytes waiting in the TCP write buffer, or 0 if unavailable."""
try:
@@ -319,6 +362,10 @@ def get_received_messages(self) -> list:
def get_queue_depth(self) -> int:
return self.command_queue.qsize()
+ def set_interesting_outputs(self, keys: frozenset[str]) -> None:
+ """Delegate to the worker, which owns the interesting-set."""
+ self._worker.set_interesting_outputs(keys)
+
def get_stats(self) -> dict:
stats = {
"queue_depth": self.get_queue_depth(),
@@ -333,6 +380,7 @@ def get_stats(self) -> dict:
def clear_queue(self) -> int:
"""Clear all pending messages from the queue, returning num cleared."""
+ self._worker._latest_outputs.clear()
cleared_count = 0
try:
while True:
diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py
index 358bc0ace..ed1b62f46 100644
--- a/modalapi/ws_protocol.py
+++ b/modalapi/ws_protocol.py
@@ -121,6 +121,23 @@ class TransportMessage:
sync_mode: SyncModeWire = "none"
+@dataclass
+class BeatSyncMessage:
+ """A sample of the transport clock (t_us=now, CLOCK_MONOTONIC) — not a
+ back-dated downbeat event. Consumers forward-extrapolate
+ pos(t) = beat_in_bar + (t - t_us) * bpm / 60, so cadence controls
+ tightness, never correctness; each sample fully replaces any prior
+ anchor. Emitted on a new bar (heartbeat) and on any discrete bpm/bpb
+ change while rolling. No absolute bar count — that's DAW-context mod-host
+ doesn't need to expose; only the fractional position within the current
+ bar matters for phase/downbeat math."""
+
+ t_us: int
+ bpm: float
+ bpb: float
+ beat_in_bar: float
+
+
@dataclass
class AddPluginMessage:
"""Plugin present in a (re)connect/load dump, or dynamically added (add ...)."""
@@ -176,6 +193,15 @@ class ParamSetMessage:
value: float
+@dataclass
+class OutputSetMessage:
+ """A plugin output-port value changed (output_set)."""
+
+ instance: str
+ symbol: str
+ value: float
+
+
@dataclass
class MidiMapMessage:
"""A MIDI binding was learned/assigned in mod-ui (midi_map ...)."""
@@ -216,12 +242,14 @@ class UnknownMessage:
TrueBypassMessage,
PluginBypassMessage,
TransportMessage,
+ BeatSyncMessage,
AddPluginMessage,
PatchSetMessage,
RemovePluginMessage,
ConnectMessage,
DisconnectMessage,
ParamSetMessage,
+ OutputSetMessage,
MidiMapMessage,
UnknownMessage,
]
@@ -338,6 +366,12 @@ def parse_message(raw_message: str) -> WebSocketMessage:
symbol, value_str = rest.split(" ", 1)
return ParamSetMessage(instance=instance, symbol=Symbol(symbol), value=float(value_str))
+ # Format: output_set /graph/{instance} {symbol} {value}
+ case ["output_set", path, rest]:
+ instance = path.removeprefix("/graph/")
+ symbol, value_str = rest.split(" ", 1)
+ return OutputSetMessage(instance=instance, symbol=symbol, value=float(value_str))
+
# Format: midi_map /graph/{instance} {symbol} {channel} {controller} {min} {max}
case ["midi_map", path, rest]:
symbol, ch, ctrl, mn, mx = rest.split(" ")[:5]
@@ -373,6 +407,16 @@ def parse_message(raw_message: str) -> WebSocketMessage:
sync_mode=cast(SyncModeWire, sync_mode),
)
+ # Format: beat_sync {t_us} {bpm} {bpb} {beat_in_bar}
+ case ["beat_sync", t_us, rest]:
+ bpm, bpb, beat_in_bar = rest.split(" ")
+ return BeatSyncMessage(
+ t_us=int(t_us),
+ bpm=float(bpm),
+ bpb=float(bpb),
+ beat_in_bar=float(beat_in_bar),
+ )
+
except (ValueError, IndexError) as e:
logging.warning(f"Failed to parse WebSocket message '{raw_message}': {e}")
return UnknownMessage(raw=raw_message)
diff --git a/pistomp/beatsync.py b/pistomp/beatsync.py
new file mode 100644
index 000000000..20230db48
--- /dev/null
+++ b/pistomp/beatsync.py
@@ -0,0 +1,129 @@
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with pi-stomp. If not, see .
+
+from dataclasses import dataclass
+
+from modalapi.ws_protocol import BeatSyncMessage
+
+
+FLASH_US = 50_000
+STALE_AFTER_US = 5_000_000
+# A clock sample landing within this many beats of a boundary is treated as
+# the crossing itself (arms the flash/bar-start immediately) rather than
+# waiting for a later tick to detect it — this is what makes a downbeat
+# sample's own arrival distinguishable, fixing the old bug where the seeded
+# anchor position was never "crossed" because it was the modulo target itself.
+_ANCHOR_CROSSING_EPSILON_BEATS = 0.05
+
+
+@dataclass(frozen=True)
+class TickState:
+ is_anchored: bool
+ is_flashing: bool
+ is_bar_start: bool
+ bpm: float
+ bpb: float
+ beat_phase: float = 0.0 # normalized [0, 1) within the current beat
+ bar_phase: float = 0.0 # normalized [0, 1) within the current bar
+
+
+class BeatGrid:
+ """Tracks the transport clock from a stream of `BeatSyncMessage` clock
+ samples: pos(t) = beat_in_bar + (t - t_us) * bpm / 60, anchored fresh from
+ each sample's own beat_in_bar (no cumulative bar count needed — mod-host
+ doesn't expose one). Downbeat is *computed* from this position
+ (`beat_index % bpb == 0`), not reconstructed from message-arrival timing —
+ so it's correct regardless of emission cadence, and self-healing: the
+ latest sample fully replaces any prior anchor, so a dropped/late one just
+ means more extrapolation, never a wrong lock."""
+
+ def __init__(self) -> None:
+ self._anchor_t_us: int | None = None
+ self._anchor_pos: float = 0.0
+ self._bpm: float = 120.0
+ self._bpb: float = 4.0
+ self._last_beat_idx: int = 0
+ self._flash_end_us: int | None = None
+ self._last_crossing_was_bar_start: bool = False
+
+ @property
+ def is_anchored(self) -> bool:
+ return self._anchor_t_us is not None
+
+ def on_anchor(self, msg: BeatSyncMessage) -> None:
+ if msg.bpm <= 0 or msg.bpb <= 0:
+ self.clear()
+ return
+ self._anchor_t_us = msg.t_us
+ self._anchor_pos = msg.beat_in_bar
+ self._bpm = msg.bpm
+ self._bpb = msg.bpb
+ self._flash_end_us = None
+ self._last_crossing_was_bar_start = False
+
+ current_beat_idx = int(self._anchor_pos // 1)
+ frac = self._anchor_pos - current_beat_idx
+ if frac < _ANCHOR_CROSSING_EPSILON_BEATS:
+ # This sample lands right at (or just past) a beat boundary — the
+ # crossing already happened at anchor time. Seed one beat behind
+ # so the very first tick() call (even at the anchor's own
+ # timestamp) detects the crossing and arms the flash/bar-start,
+ # instead of never detecting it because it *is* the modulo target.
+ self._last_beat_idx = current_beat_idx - 1
+ else:
+ self._last_beat_idx = current_beat_idx
+
+ def clear(self) -> None:
+ self._anchor_t_us = None
+ self._anchor_pos = 0.0
+ self._last_beat_idx = 0
+ self._flash_end_us = None
+ self._last_crossing_was_bar_start = False
+
+ def tick(self, now_us: int) -> TickState:
+ if self._anchor_t_us is None:
+ return TickState(False, False, False, self._bpm, self._bpb)
+
+ if self._bpm <= 0 or self._bpb <= 0:
+ self.clear()
+ return TickState(False, False, False, self._bpm, self._bpb)
+
+ if now_us - self._anchor_t_us > STALE_AFTER_US:
+ self.clear()
+ return TickState(False, False, False, self._bpm, self._bpb)
+
+ bpb_int = int(self._bpb)
+ delta_us = now_us - self._anchor_t_us
+ pos = self._anchor_pos + delta_us * self._bpm / 60_000_000.0
+ current_beat_idx = int(pos // 1)
+ beat_phase = pos - current_beat_idx # fractional part [0, 1)
+ bar_phase = (pos % self._bpb) / self._bpb
+
+ if current_beat_idx > self._last_beat_idx:
+ self._last_beat_idx = current_beat_idx
+ beat_boundary_us = self._anchor_t_us + int((current_beat_idx - self._anchor_pos) * 60_000_000.0 / self._bpm)
+ self._flash_end_us = beat_boundary_us + FLASH_US
+ self._last_crossing_was_bar_start = (current_beat_idx % bpb_int) == 0
+
+ is_flashing = self._flash_end_us is not None and now_us < self._flash_end_us
+ return TickState(
+ is_anchored=True,
+ is_flashing=is_flashing,
+ is_bar_start=is_flashing and self._last_crossing_was_bar_start,
+ bpm=self._bpm,
+ bpb=self._bpb,
+ beat_phase=beat_phase,
+ bar_phase=bar_phase,
+ )
diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py
index d4b31c25e..60674413a 100755
--- a/pistomp/footswitch.py
+++ b/pistomp/footswitch.py
@@ -172,24 +172,13 @@ def toggle_relays(self, enabled: bool):
r.disable()
def set_led(self, enabled):
- if self.led is not None:
- if self.taptempo:
- tempo = self.taptempo.get_bpm()
- if tempo:
- period = 60 / tempo
- on = 0.1
- self.led.blink(on_time=on, off_time=period - 0.1)
- elif enabled:
- self.led.on()
- else:
- self.led.off()
- if self.pixel:
- self.pixel.set_enable(enabled)
+ """Pure state update — flips fs.toggled only. The per-tick LED driver
+ (_drive_footswitch_leds in poll_controls) renders the new state to both
+ fs.pixel and fs.led on the next 10ms tick. No hardware writes here."""
+ self.toggled = enabled
def set_category(self, category):
self.category = category
- if self.pixel:
- self.pixel.set_color_by_category(category, self.toggled)
def set_lcd_color(self, color):
self.lcd_color = color
diff --git a/pistomp/handler.py b/pistomp/handler.py
index d3e2e2bf5..6f7acf632 100755
--- a/pistomp/handler.py
+++ b/pistomp/handler.py
@@ -122,6 +122,13 @@ def add_hardware(self, hardware):
def poll_controls(self):
raise NotImplementedError()
+ def _drive_footswitch_leds(self) -> None:
+ """Render footswitch LEDs from behaviors. Base implementation is a no-op;
+ Modhandler overrides with the beat-aware driver. Called from
+ poll_controls so the LED update happens in the same 10ms tick as the
+ press that triggered it."""
+ return
+
def poll_modui_changes(self):
raise NotImplementedError()
@@ -270,6 +277,8 @@ def _apply_midi_binding(
is_footswitch = self._bind_controller_to_param(plugin, param, controller)
self._add_learned_binding_row(plugin, param, controller, old_binding)
self._redraw_after_binding(controller, is_footswitch)
+ if is_footswitch:
+ self._on_footswitch_binding_changed()
def _bind_controller_to_param(self, plugin: "Plugin", param: "Parameter", controller: Controller) -> bool:
# Wire a hardware controller to a plugin parameter. Returns True if the
@@ -289,6 +298,12 @@ def _bind_controller_to_param(self, plugin: "Plugin", param: "Parameter", contro
self.current.analog_controllers[key] = display_info
return False
+ def _on_footswitch_binding_changed(self) -> None:
+ """Hook fired after a live MIDI-learn binds a footswitch to a plugin.
+ Subclasses with output_set subscriptions (Modhandler) override to
+ recompute the WS interesting-set."""
+ return
+
def _redraw_after_binding(self, controller: Controller | None, is_footswitch: bool) -> None:
# Refresh the LCD after a learned binding. Subclasses redraw at their
# own granularity.
diff --git a/pistomp/hardware.py b/pistomp/hardware.py
index b5af25d73..507673b55 100755
--- a/pistomp/hardware.py
+++ b/pistomp/hardware.py
@@ -350,8 +350,8 @@ def create_encoders(self, cfg):
self.controllers[key] = control
logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %d" % (id, midi_channel, midi_cc))
- def get_real_midi_channel(self, cfg):
- chan = 0
+ def get_real_midi_channel(self, cfg, default: int = 0):
+ chan = default
try:
val = cfg[Token.HARDWARE][Token.MIDI][Token.CHANNEL]
# LAME bug in Mod detects MIDI channel as one higher than sent (7 sent, seen by mod as 8) so compensate here
@@ -425,10 +425,15 @@ def __apply_midi_routing(self, cfg):
self.__route_section(cfg, Token.FOOTSWITCHES, self.footswitches, set_cc=False)
def __init_midi_default(self):
- self.__init_midi(self.cfg)
-
- def __init_midi(self, cfg):
- self.midi_channel = self.get_real_midi_channel(cfg)
+ self.__init_midi(self.cfg, default=0)
+
+ def __init_midi(self, cfg, default: int | None = None):
+ # A pedalboard overlay declaring no channel keeps the one already in
+ # force. Falling back to 0 there would re-key every controller under
+ # "0:" while the parameter bindings still say ":", and the
+ # switch would silently stop dispatching.
+ fallback = self.midi_channel if default is None else default
+ self.midi_channel = self.get_real_midi_channel(cfg, default=fallback)
# TODO could iterate thru all objects here instead of handling in __init_footswitches
for ac in self.analog_controls:
if isinstance(ac, AnalogMidiControl.AnalogMidiControl):
diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py
index 0e69d2976..233f69bb3 100644
--- a/pistomp/lcd320x240.py
+++ b/pistomp/lcd320x240.py
@@ -18,6 +18,7 @@
import functools
import logging
import os
+import re
import time
import socket
from collections.abc import Callable, Iterator
@@ -27,6 +28,7 @@
import common.util as util
from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type
+from modalapi.led_render import render_led_spec, state_label
from modalapi.plugin import Plugin
from ui.ethernet_menu import EthernetMenu
from ui.footswitch_menu import FootswitchMenu
@@ -55,6 +57,7 @@
Parameterdialog,
ScrollingText,
TextWidget,
+ LoopPluginTile,
)
from uilib.glyphs.badge import BadgeGlyph
from uilib.gridpanel import GridPanel, TILE_W, CHANNEL
@@ -613,13 +616,10 @@ def _draw_plugins(self):
def tile_factory(node, box, parent):
plugin = plugins_by_id[node.id]
display_name = plugin.display_name
- label = display_name[: self.plugin_label_length].replace("_", "")
- label = self.shorten_name(label, box.width)
subtitle = plugin.subtitle or (f"{plugin.category}: {display_name}" if plugin.category else display_name)
- tile = PluginTile(
+ common_kw = dict(
plugin=plugin,
box=box,
- text=label,
outline_radius=5,
parent=parent,
action=self.plugin_event,
@@ -628,6 +628,14 @@ def tile_factory(node, box, parent):
backdrop=self.background,
foreground=self.foreground,
)
+ if plugin.customization.loop_icon:
+ m = re.search(r"\d+$", display_name)
+ loop_num = int(m.group()) if m else 0
+ tile = LoopPluginTile(loop_num=loop_num, **common_kw)
+ else:
+ label = display_name[: self.plugin_label_length].replace("_", "")
+ label = self.shorten_name(label, box.width)
+ tile = PluginTile(text=label, **common_kw)
tile.set_font(self.small_font)
self.w_plugins.append(tile)
return tile
@@ -860,6 +868,32 @@ def footswitch_label(self, footswitch, slot_width=None):
name = param.instance_id
return self.shorten_name(name, width)
+ def _footswitch_state(self, footswitch):
+ """(name, state_label, color, loop_icon) for a switch bound to a plugin that
+ publishes a state via its LedSpec, else (None, None, None, False). The name is
+ the plugin's, not the bound port's — the port is a trigger ("Advance"),
+ which says nothing about which loop this is."""
+ param = footswitch.parameter
+ if param is None or self.current is None:
+ return None, None, None, False
+ plugin = self.current.pedalboard.find_plugin(param.instance_id)
+ if plugin is None:
+ return None, None, None, False
+ spec = plugin.customization.led_spec
+ if spec is None:
+ return None, None, None, False
+ label = state_label(spec, plugin.output_values)
+ if label is None:
+ return None, None, None, False
+ color, _style = render_led_spec(spec, plugin.output_values)
+ return plugin.display_name, label, color, plugin.customization.loop_icon
+
+ def _progress_fn(self, footswitch, state_label: str | None):
+ """Only the state view has a border to draw the loop position on."""
+ if state_label is None or self.handler is None:
+ return None
+ return functools.partial(self.handler.footswitch_loop_progress, footswitch)
+
def draw_footswitches(self):
# One slot-ordered pass over the physical switches, so selection order is
# the stable physical order regardless of plugin/pedalboard ordering.
@@ -872,6 +906,7 @@ def draw_footswitches(self):
slot_w = pitch
for fs in sorted(self.footswitches, key=lambda f: f.id):
x = pitch * fs.id
+ state = None
if fs.preset_callback_arg is not None:
label = self.footswitch_label(fs, slot_w)
fs.set_display_label(label)
@@ -883,9 +918,15 @@ def draw_footswitches(self):
fs.toggled = active
fs.set_led(active) # a press never touches toggled for preset switches
elif fs.parameter is not None:
- label = self.footswitch_label(fs, slot_w)
+ name, state, state_color, loop_icon = self._footswitch_state(fs)
+ if state is not None:
+ label = name
+ color = state_color
+ else:
+ label = self.footswitch_label(fs, slot_w)
+ color = accent_color_for(fs.category)
+ loop_icon = False
fs.set_display_label(label)
- color = accent_color_for(fs.category)
action = self.footswitch_event
else:
label = fs.get_display_label() or ""
@@ -898,10 +939,14 @@ def draw_footswitches(self):
not fs.toggled,
small_font=self.tiny_font,
taptempo=fs.taptempo,
+ state_label=state,
+ progress_fn=self._progress_fn(fs, state),
+ loop_icon=loop_icon if state is not None else False,
parent=self.footswitch_panel,
action=action,
object=fs,
)
+ p.poll_progress()
self.w_footswitches.append(p)
self.footswitch_panel.refresh()
@@ -915,16 +960,30 @@ def update_footswitch(self, footswitch):
footswitch.toggled = active
footswitch.set_led(active)
wfs.color = FootswitchWidget.DEFAULT_COLOR
+ wfs.state_label = None
+ wfs.progress_fn = None
elif footswitch.parameter is not None:
# Binding may be new (e.g. MIDI learn) — reflect label + color.
- footswitch.set_display_label(self.footswitch_label(footswitch, slot_w))
- wfs.color = accent_color_for(footswitch.category)
+ name, state, state_color, loop_icon = self._footswitch_state(footswitch)
+ if state is not None:
+ footswitch.set_display_label(name)
+ wfs.color = state_color
+ wfs.loop_icon = loop_icon
+ else:
+ footswitch.set_display_label(self.footswitch_label(footswitch, slot_w))
+ wfs.color = accent_color_for(footswitch.category)
+ wfs.loop_icon = False
+ wfs.state_label = state
+ wfs.progress_fn = self._progress_fn(footswitch, state)
wfs.action = self.footswitch_event
else:
wfs.color = None
wfs.action = None
+ wfs.state_label = None
+ wfs.progress_fn = None
wfs.toggle(not footswitch.toggled)
wfs.label = footswitch.get_display_label() or ""
+ wfs.poll_progress()
wfs.refresh()
break
diff --git a/plugins/__init__.py b/plugins/__init__.py
index afdac63e7..5f5615933 100644
--- a/plugins/__init__.py
+++ b/plugins/__init__.py
@@ -67,6 +67,7 @@
import plugins.pinned_params # noqa: F401 # explicit pinned-param customizations
import plugins.mixer # noqa: F401
import plugins.layouts # noqa: F401 # Layout components
+import plugins.loopjefe # noqa: F401 # LoopJefe footswitch behavior
import plugins.redundant_ports # noqa: F401 # curated hidden_params, no panels
import plugins.transport # noqa: F401 # /pedalboard :bpm/:bpb/:rolling labels
diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py
new file mode 100644
index 000000000..68265c2f8
--- /dev/null
+++ b/plugins/loopjefe/__init__.py
@@ -0,0 +1,87 @@
+"""LoopJefe multitrack looper plugin customization.
+
+Declarative footswitch-LED spec only: state colors + loop-downbeat tint,
+interpreted by the handler's generic LED driver (modalapi/led_render.py).
+Momentary press semantics come for free from `advance`/`reset` being
+`pprops:trigger` ports (common/parameter.py) — no plugin-specific input code.
+"""
+
+from __future__ import annotations
+
+import re
+from typing import TYPE_CHECKING
+
+from modalapi.plugin_customization import LedSpec, PluginCustomization
+from plugins.customization import register
+
+if TYPE_CHECKING:
+ from modalapi.plugin import Plugin
+
+LOOPJEFE_URIS = (
+ "http://treefallsound.com/plugins/loopjefe",
+ "http://treefallsound.com/plugins/loopjefe-2x2",
+)
+
+# LoopJefePlugin state values (../loopjefe-lv2/src/types.h)
+_STATE_EMPTY = 0
+_STATE_RECORDING = 2
+_STATE_RECORD_CLOSE = 3
+_STATE_STOPPED = 5
+
+_STATE_COLORS: dict[int, tuple[int, int, int]] = {
+ 1: (0, 80, 255), # Record Arm
+ 2: (255, 0, 0), # Recording
+ 3: (0, 80, 255), # Record Close
+ 4: (0, 255, 0), # Playback
+ _STATE_STOPPED: (80, 80, 80),
+ 6: (0, 80, 255), # Overdub Arm
+ 7: (255, 140, 0), # Overdub
+ 8: (0, 80, 255), # Overdub Close
+}
+
+# Short enough for a 320px/4 footswitch slot; the TTL scalePoints spell them
+# out in full ("Record Arm", "Overdub Close").
+_STATE_LABELS: dict[int, str] = {
+ _STATE_EMPTY: "\u00b7",
+ 1: "Arm",
+ 2: "Rec",
+ 3: "Close",
+ 4: "Play",
+ _STATE_STOPPED: "Stop",
+ 6: "Arm",
+ 7: "Dub",
+ 8: "Close",
+}
+
+_LOOPJEFE_LED_SPEC = LedSpec(
+ state_symbol="state",
+ colors=_STATE_COLORS,
+ labels=_STATE_LABELS,
+ pulse=True,
+ off_states=frozenset({_STATE_EMPTY}),
+ steady_states=frozenset({_STATE_STOPPED}),
+ downbeat_symbol="measure_number",
+ downbeat_tint=60,
+ bars_symbol="loop_bars",
+ # The initial take has no length yet to be a fraction of, so the progress
+ # border sweeps instead of filling. Record Arm is excluded: nothing is
+ # being captured, so nothing should move.
+ chase_states=frozenset({_STATE_RECORDING, _STATE_RECORD_CLOSE}),
+)
+
+def _track_name(plugin: "Plugin") -> str | None:
+ """"Loop 2", not "LoopJefe" — every track is the same plugin, so the
+ instance number is the only thing that tells two switches apart."""
+ match = re.search(r"(\d+)$", plugin.instance_id)
+ return f"Loop {match.group(1)}" if match else None
+
+
+register(
+ *LOOPJEFE_URIS,
+ customization=PluginCustomization(
+ display_name="LoopJefe",
+ display_name_fn=_track_name,
+ led_spec=_LOOPJEFE_LED_SPEC,
+ loop_icon=True,
+ ),
+)
diff --git a/tests/conftest.py b/tests/conftest.py
index 2bbff1c3d..39e302ca7 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -156,6 +156,7 @@ class FakeWebSocketBridge:
def __init__(self):
self.sent: list[str] = []
self._inbox: list[str] = []
+ self.interesting_calls: list[frozenset[str]] = []
def start(self) -> None:
pass
@@ -174,6 +175,9 @@ def send_bpm(self, bpm: float) -> bool:
def clear_queue(self) -> int:
return 0
+ def set_interesting_outputs(self, keys: frozenset[str]) -> None:
+ self.interesting_calls.append(keys)
+
def get_received_messages(self) -> list[str]:
msgs, self._inbox = self._inbox, []
return msgs
diff --git a/tests/pedalboard_fixtures.py b/tests/pedalboard_fixtures.py
index a6e33fc64..2fc0cdd00 100644
--- a/tests/pedalboard_fixtures.py
+++ b/tests/pedalboard_fixtures.py
@@ -59,6 +59,11 @@ def tile_active_color(self) -> tuple[int, int, int] | None:
def tile_border(self):
return None
+ @property
+ def customization(self):
+ from modalapi.plugin_customization import PluginCustomization
+ return PluginCustomization()
+
@property
def panel_cls(self):
return None
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png
new file mode 100644
index 000000000..a3fbd43ed
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png
new file mode 100644
index 000000000..17f33709e
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png
new file mode 100644
index 000000000..d4adfb23f
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png
new file mode 100644
index 000000000..c7ad6c09d
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png
new file mode 100644
index 000000000..c3875d459
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png
new file mode 100644
index 000000000..7ee2dcc87
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png
new file mode 100644
index 000000000..58dc7677d
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png
new file mode 100644
index 000000000..dade80827
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png
new file mode 100644
index 000000000..fe741149b
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png differ
diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png
new file mode 100644
index 000000000..6e53604b8
Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png differ
diff --git a/tests/test_beatsync.py b/tests/test_beatsync.py
new file mode 100644
index 000000000..be94c20f0
--- /dev/null
+++ b/tests/test_beatsync.py
@@ -0,0 +1,236 @@
+"""BeatGrid — anchor + tick math for the metronome LED scheduler."""
+
+from modalapi.ws_protocol import BeatSyncMessage
+from pistomp.beatsync import FLASH_US, STALE_AFTER_US, BeatGrid, TickState
+
+
+def _anchor(t_us=0, bpm=120.0, bpb=4.0, beat_in_bar=0.0) -> BeatSyncMessage:
+ return BeatSyncMessage(t_us=t_us, bpm=bpm, bpb=bpb, beat_in_bar=beat_in_bar)
+
+
+class TestUnanchored:
+ def test_fresh_grid_is_not_anchored(self):
+ assert BeatGrid().is_anchored is False
+
+ def test_unanchored_tick_reports_unanchored(self):
+ state = BeatGrid().tick(now_us=1_000_000)
+ assert state.is_anchored is False
+ assert state.is_flashing is False
+ assert state.is_bar_start is False
+
+ def test_clear_is_idempotent(self):
+ g = BeatGrid()
+ g.clear()
+ g.clear()
+ assert g.is_anchored is False
+
+
+class TestAnchor:
+ def test_anchor_marks_anchored(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ assert g.is_anchored is True
+
+ def test_anchor_on_downbeat_flashes_and_marks_bar_start_immediately(self):
+ """The bug fix: a clock sample that *is* a downbeat (beat_in_bar=0)
+ must be visible at the anchor's own timestamp — waiting for a later
+ crossing would mean is_bar_start never fires (it was already the
+ modulo target, never something to cross into)."""
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0, beat_in_bar=0.0))
+ state = g.tick(now_us=1_000_000)
+ assert state.is_anchored is True
+ assert state.is_flashing is True
+ assert state.is_bar_start is True
+
+ def test_anchor_mid_bar_does_not_flash_immediately(self):
+ """A clock sample taken mid-bar (e.g. a bpm-change re-anchor) is not a
+ crossing — no flash until the next real beat boundary."""
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0, beat_in_bar=1.5))
+ state = g.tick(now_us=1_000_000)
+ assert state.is_anchored is True
+ assert state.is_flashing is False
+
+ def test_anchor_at_late_time_does_not_catch_up(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + 4 * 500_000)
+ assert state.is_anchored is True
+ assert state.is_flashing is True
+ # One flash, not four — verify the next tick is past the flash window
+ # and the *following* beat boundary fires exactly one more.
+ state = g.tick(now_us=1_000_000 + 4 * 500_000 + FLASH_US + 1)
+ assert state.is_flashing is False
+
+
+class TestFlash:
+ def test_first_beat_after_anchor_flashes(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + 500_000)
+ assert state.is_flashing is True
+ assert state.is_bar_start is False
+
+ def test_flash_expires_after_flash_us(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.tick(now_us=1_000_000 + 500_000)
+ state = g.tick(now_us=1_000_000 + 500_000 + FLASH_US)
+ assert state.is_flashing is False
+
+ def test_late_tick_uses_source_boundary_for_flash_cutoff(self):
+ g = BeatGrid()
+ boundary = 1_000_000 + 500_000
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+
+ state = g.tick(now_us=boundary + 20_000)
+ assert state.is_flashing is True
+
+ state = g.tick(now_us=boundary + FLASH_US)
+ assert state.is_flashing is False
+
+ def test_bar_start_marked_on_downbeat(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.tick(now_us=1_000_000 + 500_000)
+ g.tick(now_us=1_000_000 + 1_000_000)
+ g.tick(now_us=1_000_000 + 1_500_000)
+ state = g.tick(now_us=1_000_000 + 2_000_000)
+ assert state.is_flashing is True
+ assert state.is_bar_start is True
+
+ def test_subsequent_beats_flash_in_sequence(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ flashes = []
+ for i in range(8):
+ t = 1_000_000 + 500_000 * (i + 1)
+ state = g.tick(now_us=t)
+ flashes.append(state.is_flashing)
+ assert flashes == [True] * 8
+
+ def test_subsequent_bar_starts_every_bpb_beats(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=0, bpm=120.0, bpb=4.0))
+ bar_starts = []
+ for i in range(8):
+ t = 500_000 * (i + 1)
+ state = g.tick(now_us=t)
+ bar_starts.append(state.is_bar_start)
+ assert bar_starts == [False, False, False, True, False, False, False, True]
+
+
+class TestClear:
+ def test_clear_after_anchor(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.clear()
+ assert g.is_anchored is False
+ state = g.tick(now_us=2_000_000)
+ assert state.is_anchored is False
+
+ def test_clear_mid_flash(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.tick(now_us=1_000_000 + 500_000)
+ g.clear()
+ state = g.tick(now_us=1_000_000 + 600_000)
+ assert state.is_flashing is False
+
+
+class TestStaleTimeout:
+ def test_stale_anchor_clears_on_tick(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + STALE_AFTER_US + 1)
+ assert state.is_anchored is False
+
+ def test_freshly_anchored_is_not_stale(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + STALE_AFTER_US - 1)
+ assert state.is_anchored is True
+
+
+class TestInvalidAnchor:
+ def test_zero_bpm_clears_grid(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=0.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + 500_000)
+ assert state.is_anchored is False
+
+ def test_zero_bpb_clears_grid(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=0.0))
+ state = g.tick(now_us=1_000_000 + 500_000)
+ assert state.is_anchored is False
+
+
+class TestReAnchor:
+ def test_re_anchor_resets_beat_counter(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.tick(now_us=1_000_000 + 1_500_000)
+ g.on_anchor(_anchor(t_us=10_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=10_000_000 + 500_000)
+ assert state.is_flashing is True
+ assert state.is_bar_start is False
+
+ def test_re_anchor_skips_missed_beats(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0))
+ g.on_anchor(_anchor(t_us=1_000_000 + 4_000_000, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=1_000_000 + 4_000_000 + 500_000)
+ assert state.is_flashing is True
+ # First tick past the new anchor fires for the next live beat
+ # (beat 1, not a bar start). The 4 missed beats did not cause a
+ # flurry of catches-up.
+ assert state.is_bar_start is False
+
+
+class TestTickState:
+ def test_tick_state_is_immutable(self):
+ state = TickState(True, True, True, 120.0, 4.0)
+ try:
+ state.is_flashing = False # type: ignore[misc]
+ except Exception:
+ return
+ raise AssertionError("TickState should be frozen")
+
+
+class TestBeatPhase:
+ """beat_phase remains the normalized [0, 1) within-beat position used
+ for loop position; flash brightness is driven by is_flashing."""
+
+ def test_phase_zero_at_beat_boundary(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=0, bpm=120.0, bpb=4.0)) # 120bpm → 500ms/beat
+ state = g.tick(now_us=500_000) # exactly beat 1
+ assert state.beat_phase == 0.0
+
+ def test_phase_advances_within_beat(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=0, bpm=120.0, bpb=4.0))
+ state = g.tick(now_us=125_000) # 1/4 of a 500ms beat
+ assert 0.0 <= state.beat_phase < 1.0
+ assert abs(state.beat_phase - 0.25) < 0.01
+
+ def test_phase_resets_across_beat_boundary(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=0, bpm=120.0, bpb=4.0))
+ g.tick(now_us=500_000) # beat 1
+ state = g.tick(now_us=750_000) # halfway through beat 2
+ assert abs(state.beat_phase - 0.5) < 0.01
+
+ def test_phase_in_range_zero_to_one(self):
+ g = BeatGrid()
+ g.on_anchor(_anchor(t_us=0, bpm=120.0, bpb=4.0))
+ for t_us in range(0, 2_000_000, 50_000):
+ state = g.tick(now_us=t_us)
+ assert 0.0 <= state.beat_phase < 1.0
+
+ def test_phase_is_zero_when_unanchored(self):
+ g = BeatGrid()
+ state = g.tick(now_us=1_000_000)
+ assert state.beat_phase == 0.0
diff --git a/tests/test_hardware.py b/tests/test_hardware.py
index 502f630ef..4d2468fa0 100644
--- a/tests/test_hardware.py
+++ b/tests/test_hardware.py
@@ -141,6 +141,10 @@ def test_external_port_opened_eagerly(self, routed_hw):
assert "My MIDI Device" in routed_hw.external_midi.midi_ports
+def _init_footswitches(hw, cfg):
+ hw._Hardware__init_footswitches(cfg)
+
+
class TestReinitDefaultRouting:
def test_reinit_applies_routing_for_default_cfg(self, monkeypatch):
"""Routing is applied for the default config, not only for pedalboard cfg."""
diff --git a/tests/test_loopjefe_behavior.py b/tests/test_loopjefe_behavior.py
new file mode 100644
index 000000000..58af6846d
--- /dev/null
+++ b/tests/test_loopjefe_behavior.py
@@ -0,0 +1,92 @@
+"""LoopJefe footswitch LED spec — state->color/style contract + registration.
+
+Pins:
+ - The loopjefe URIs are registered (plugins/__init__.py imports plugins.loopjefe).
+ - The registered LedSpec renders all 9 states correctly via the generic
+ render_led_spec driver, including the measure_number==0 loop-downbeat tint.
+ - Momentary press semantics come from the port (pprops:trigger on
+ advance/reset), not from anything here — not tested in this file.
+ - Brightness/pulse envelope is the driver's job — not tested here.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from modalapi.led_render import LedDisplayStyle, render_led_spec
+from modalapi.plugin_customization import LedSpec
+from plugins import lookup, registered_uris
+from plugins.loopjefe import LOOPJEFE_URIS
+
+
+def _spec() -> LedSpec:
+ spec = lookup(LOOPJEFE_URIS[0]).led_spec
+ assert spec is not None
+ return spec
+
+
+class TestRegistration:
+ def test_loopjefe_uris_are_registered(self):
+ registered = registered_uris()
+ for uri in LOOPJEFE_URIS:
+ assert uri in registered, f"{uri} not registered — plugins/__init__.py must import plugins.loopjefe"
+
+ def test_lookup_returns_loopjefe_led_spec(self):
+ for uri in LOOPJEFE_URIS:
+ cust = lookup(uri)
+ assert cust.led_spec is not None, f"lookup({uri!r}) did not return the loopjefe LedSpec"
+ assert cust.led_spec.state_symbol == "state"
+ assert cust.led_spec.downbeat_symbol == "measure_number"
+
+
+class TestStateColorAndStyle:
+ @pytest.mark.parametrize(
+ "state,expected_color",
+ [
+ (0, None), # Empty -> off
+ (1, (0, 80, 255)), # Record Arm -> blue
+ (2, (255, 0, 0)), # Recording -> red
+ (3, (0, 80, 255)), # Record Close -> blue
+ (4, (0, 255, 0)), # Playback -> green
+ (5, (80, 80, 80)), # Stopped -> steady grey
+ (6, (0, 80, 255)), # Overdub Arm -> blue
+ (7, (255, 140, 0)), # Overdub -> orange
+ (8, (0, 80, 255)), # Overdub Close -> blue
+ ],
+ )
+ def test_state_color_with_nonzero_measure(self, state, expected_color):
+ color, _style = render_led_spec(_spec(), {"state": float(state), "measure_number": 1.0})
+ assert color == expected_color
+
+ @pytest.mark.parametrize(
+ "state,expected_style",
+ [
+ (0, LedDisplayStyle.SOLID), # Empty -> off, solid
+ (5, LedDisplayStyle.SOLID), # Stopped -> steady grey
+ (1, LedDisplayStyle.METRONOME), # active -> pulse
+ (2, LedDisplayStyle.METRONOME),
+ (3, LedDisplayStyle.METRONOME),
+ (4, LedDisplayStyle.METRONOME),
+ (6, LedDisplayStyle.METRONOME),
+ (7, LedDisplayStyle.METRONOME),
+ (8, LedDisplayStyle.METRONOME),
+ ],
+ )
+ def test_state_style(self, state, expected_style):
+ _color, style = render_led_spec(_spec(), {"state": float(state), "measure_number": 1.0})
+ assert style == expected_style
+
+
+class TestLoopDownbeatTint:
+ def test_measure_zero_returns_distinct_color(self):
+ downbeat, _ = render_led_spec(_spec(), {"state": 2.0, "measure_number": 0.0}) # Recording -> red
+ normal, _ = render_led_spec(_spec(), {"state": 2.0, "measure_number": 2.0})
+ assert downbeat is not None and normal is not None
+ assert downbeat != normal
+ # The downbeat tint brightens each channel that wasn't already at 255
+ assert all(d >= n for d, n in zip(downbeat, normal))
+ assert any(d > n for d, n in zip(downbeat, normal))
+
+ def test_measure_zero_empty_state_still_off(self):
+ color, _style = render_led_spec(_spec(), {"state": 0.0, "measure_number": 0.0})
+ assert color is None
diff --git a/tests/test_websocket_bridge.py b/tests/test_websocket_bridge.py
index c3d7cbb5c..0e1396df6 100644
--- a/tests/test_websocket_bridge.py
+++ b/tests/test_websocket_bridge.py
@@ -158,6 +158,82 @@ def test_receive_output_set_is_dropped():
assert ws._sent == []
+def test_receive_output_set_subscribed_survives():
+ worker = _make_worker()
+ worker.running = True
+ worker.set_interesting_outputs(frozenset({"loopjefe/state", "loopjefe/measure_number"}))
+ ws = _FakeWs(["output_set /graph/loopjefe state 2.0"])
+
+ asyncio.run(worker._receive_messages(ws))
+
+ msgs = []
+ while not worker.received_queue.empty():
+ msgs.append(worker.received_queue.get_nowait())
+ assert msgs == ["output_set /graph/loopjefe state 2.0"]
+ assert worker.messages_received == 1
+
+
+def test_receive_output_set_unsubscribed_is_dropped():
+ worker = _make_worker()
+ worker.running = True
+ worker.set_interesting_outputs(frozenset({"loopjefe/state"}))
+ ws = _FakeWs(["output_set /graph/Delay/meter 0.5"])
+
+ asyncio.run(worker._receive_messages(ws))
+
+ assert worker.received_queue.empty()
+ assert worker.messages_received == 0
+
+
+def test_receive_output_set_empty_interesting_drops_all():
+ """Regression: empty interesting-set must reproduce today's behavior —
+ every output_set is dropped before it floods the queue."""
+ worker = _make_worker()
+ worker.running = True
+ worker.set_interesting_outputs(frozenset())
+ ws = _FakeWs(["output_set /graph/loopjefe state 2.0", "output_set /graph/Amp/meter 0.9"])
+
+ asyncio.run(worker._receive_messages(ws))
+
+ assert worker.received_queue.empty()
+ assert worker.messages_received == 0
+
+
+def test_set_interesting_outputs_swaps_atomically():
+ """A subscription set swap takes effect immediately for subsequent frames;
+ the worker never sees a partially-updated set."""
+ worker = _make_worker()
+ worker.running = True
+ worker.set_interesting_outputs(frozenset({"loopjefe/state"}))
+ ws = _FakeWs([
+ "output_set /graph/loopjefe state 1.0", # subscribed → kept
+ "output_set /graph/loopjefe measure_number 0.0", # not subscribed → dropped
+ ])
+
+ asyncio.run(worker._receive_messages(ws))
+
+ msgs = []
+ while not worker.received_queue.empty():
+ msgs.append(worker.received_queue.get_nowait())
+ assert msgs == ["output_set /graph/loopjefe state 1.0"]
+
+
+def test_worker_does_not_hold_bridge_reference():
+ """Layering: the worker must not know about the bridge. The bridge owns the
+ worker, so a back-reference inverts the dependency and lets the worker reach
+ into bridge internals. The worker owns its own interesting-set instead."""
+ import inspect
+ worker = _make_worker()
+ sig = inspect.signature(WebSocketWorker.__init__)
+ assert "bridge" not in sig.parameters, (
+ "WebSocketWorker.__init__ must not take a bridge param — the worker "
+ "should own its interesting-set, not reach back into the bridge"
+ )
+ assert not hasattr(worker, "_bridge"), (
+ "WebSocketWorker must not store a _bridge reference"
+ )
+
+
def test_receive_mixed_messages_routes_correctly():
worker = _make_worker()
worker.running = True
@@ -313,3 +389,54 @@ def test_notify_before_worker_starts_is_a_noop():
bridge = _make_bridge()
bridge.send_parameter("a", Symbol("x"), 1.0)
assert bridge.get_queue_depth() == 1
+
+
+def test_output_set_before_subscription_is_replayed():
+ """mod-ui dumps every monitored port on connect, which lands before the
+ board binds and the subscriptions are known. Dropping those frames outright
+ left the first paint stale until the plugin next moved -- a looper already
+ in Playback rendered as "Empty" indefinitely."""
+ worker = _make_worker()
+ worker.running = True
+ ws = _FakeWs(["output_set /graph/loopjefe_1 state 4.0"])
+
+ asyncio.run(worker._receive_messages(ws))
+ assert worker.received_queue.empty() # nothing subscribed yet
+
+ worker.set_interesting_outputs(frozenset({"loopjefe_1/state"}))
+
+ msgs = []
+ while not worker.received_queue.empty():
+ msgs.append(worker.received_queue.get_nowait())
+ assert msgs == ["output_set /graph/loopjefe_1 state 4.0"]
+
+
+def test_replayed_output_set_is_the_latest_value():
+ """Only the newest value per port is held; the dump can carry several."""
+ worker = _make_worker()
+ worker.running = True
+ ws = _FakeWs([
+ "output_set /graph/loopjefe_1 state 1.0",
+ "output_set /graph/loopjefe_1 state 4.0",
+ ])
+
+ asyncio.run(worker._receive_messages(ws))
+ worker.set_interesting_outputs(frozenset({"loopjefe_1/state"}))
+
+ msgs = []
+ while not worker.received_queue.empty():
+ msgs.append(worker.received_queue.get_nowait())
+ assert msgs == ["output_set /graph/loopjefe_1 state 4.0"]
+
+
+def test_unsubscribed_output_set_is_never_replayed():
+ """A port nothing subscribes to stays out of the queue entirely."""
+ worker = _make_worker()
+ worker.running = True
+ ws = _FakeWs(["output_set /graph/Amp meter 0.9"])
+
+ asyncio.run(worker._receive_messages(ws))
+ worker.set_interesting_outputs(frozenset({"loopjefe_1/state"}))
+
+ assert worker.received_queue.empty()
+ assert worker.messages_received == 0
diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py
index c1b86fa2e..c2373be6e 100644
--- a/tests/test_ws_protocol.py
+++ b/tests/test_ws_protocol.py
@@ -4,11 +4,13 @@
PatchSetMessage,
AddHwPortMessage,
AddPluginMessage,
+ BeatSyncMessage,
ConnectMessage,
DisconnectMessage,
LoadingEndMessage,
LoadingStartMessage,
MidiMapMessage,
+ OutputSetMessage,
PedalSnapshotMessage,
ParamSetMessage,
PluginBypassMessage,
@@ -263,6 +265,43 @@ def test_transport_malformed_bpm_is_unknown():
)
+# ---------------------------------------------------------------------------
+# beat_sync (beat_sync {t_us} {bpm} {bpb} {beat_in_bar}) — a clock sample
+# (t_us=now), not a back-dated downbeat event. No absolute bar count — that's
+# DAW context mod-host doesn't need to expose.
+# ---------------------------------------------------------------------------
+
+
+def test_beat_sync_basic():
+ assert parse_message("beat_sync 1234567890 120.0 4 0.0") == BeatSyncMessage(
+ t_us=1234567890, bpm=120.0, bpb=4.0, beat_in_bar=0.0
+ )
+
+
+def test_beat_sync_zero_beat_in_bar():
+ assert parse_message("beat_sync 0 60.0 3 0.0") == BeatSyncMessage(
+ t_us=0, bpm=60.0, bpb=3.0, beat_in_bar=0.0
+ )
+
+
+def test_beat_sync_fractional_bpb():
+ assert parse_message("beat_sync 1000000 90.5 7 2.5") == BeatSyncMessage(
+ t_us=1000000, bpm=90.5, bpb=7.0, beat_in_bar=2.5
+ )
+
+
+def test_beat_sync_too_few_fields_is_unknown():
+ assert isinstance(parse_message("beat_sync 5 1234567890 120.0"), UnknownMessage)
+
+
+def test_beat_sync_non_int_t_us_is_unknown():
+ assert isinstance(parse_message("beat_sync 5 notanumber 120.0 4"), UnknownMessage)
+
+
+def test_beat_sync_non_float_bpm_is_unknown():
+ assert isinstance(parse_message("beat_sync 5 1234567890 notanumber 4"), UnknownMessage)
+
+
def test_plugin_bypass_nonzero_is_true():
msg = parse_message("param_set /graph/Reverb :bypass 0.5")
assert msg == PluginBypassMessage(instance="Reverb", bypassed=True)
@@ -385,6 +424,36 @@ def test_empty_string():
assert isinstance(msg, UnknownMessage)
+# ---------------------------------------------------------------------------
+# output_set (output_set /graph/{instance} {symbol} {value})
+# ---------------------------------------------------------------------------
+
+
+def test_output_set_parses_to_output_set_message():
+ msg = parse_message("output_set /graph/loopjefe state 2.0")
+ assert msg == OutputSetMessage(instance="loopjefe", symbol="state", value=2.0)
+
+
+def test_output_set_integer_port():
+ msg = parse_message("output_set /graph/loopjefe measure_number 0.0")
+ assert msg == OutputSetMessage(instance="loopjefe", symbol="measure_number", value=0.0)
+
+
+def test_output_set_missing_value_is_unknown():
+ msg = parse_message("output_set /graph/loopjefe state")
+ assert isinstance(msg, UnknownMessage)
+
+
+def test_output_set_non_float_value_is_unknown():
+ msg = parse_message("output_set /graph/loopjefe state notanumber")
+ assert isinstance(msg, UnknownMessage)
+
+
+def test_output_set_strips_graph_prefix():
+ msg = parse_message("output_set /graph/loopjefe state 4.0")
+ assert msg == OutputSetMessage(instance="loopjefe", symbol="state", value=4.0)
+
+
# ---------------------------------------------------------------------------
# patch_set — writable plugin properties (frames captured off a live device)
# ---------------------------------------------------------------------------
diff --git a/tests/v3/test_footswitch_led_driver.py b/tests/v3/test_footswitch_led_driver.py
new file mode 100644
index 000000000..ed048bdbb
--- /dev/null
+++ b/tests/v3/test_footswitch_led_driver.py
@@ -0,0 +1,188 @@
+"""Unified footswitch LED driver — single source of truth for pixel + GPIO LED.
+
+The driver runs in poll_controls (10ms, same tick as the press) so there's no
+latency between a state change and the LED reflecting it. Both fs.pixel and
+fs.led are written from the same (color, style) frame in the same driver call
+— no separate set_led path.
+
+ - SOLID: shows the frame's color steadily (or off when color is None).
+ - METRONOME: is fully on during the transport flash window and off otherwise.
+ - Unanchored METRONOME: steady color (no transport pulse).
+ - Off: color is None -> pixel disabled, GPIO LED off.
+ - Press renders in the same tick (poll_controls, not poll_indicators).
+ - set_led is a pure state update -- no hardware writes.
+ - Default per-footswitch renderer (no bound plugin / no LedSpec): toggle +
+ category color, falling back to off when not toggled.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from modalapi.led_render import LedDisplayStyle
+from modalapi.modhandler import Modhandler
+from pistomp.beatsync import TickState
+from pistomp.footswitch import Footswitch
+from tests.types import SystemFixture
+
+
+def _beat(
+ beat_phase: float = 0.0, *, is_anchored: bool = True, is_bar_start: bool = False, is_flashing: bool | None = None
+) -> TickState:
+ if is_flashing is None:
+ is_flashing = is_bar_start
+ return TickState(
+ is_anchored=is_anchored,
+ is_flashing=is_flashing,
+ is_bar_start=is_bar_start,
+ bpm=120.0,
+ bpb=4.0,
+ beat_phase=beat_phase,
+ )
+
+
+def _drive(handler: Modhandler, beat: TickState) -> None:
+ """Invoke the driver directly with a fabricated beat state."""
+ handler._drive_footswitch_leds(beat)
+
+
+def _fs_with_frame(v3_system: SystemFixture, color, style: LedDisplayStyle = LedDisplayStyle.SOLID) -> Footswitch:
+ """Stub the default per-footswitch renderer to return a fixed frame,
+ bypassing plugin-binding lookup entirely — isolates the writer/envelope
+ behavior under test from LedSpec rendering (covered in
+ tests/test_loopjefe_behavior.py)."""
+ fs = v3_system.hw.footswitches[0]
+ v3_system.handler._render_footswitch = MagicMock(return_value=(color, style)) # type: ignore[method-assign]
+ fs.pixel = MagicMock()
+ fs.led = MagicMock()
+ return fs
+
+
+class TestSolidStyle:
+ def test_solid_shows_color_steadily(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (0, 255, 0), LedDisplayStyle.SOLID)
+ _drive(v3_system.handler, _beat(beat_phase=0.0))
+ fs.pixel.set_color.assert_called_once_with((0, 255, 0))
+ fs.pixel.set_enable.assert_called_once_with(True)
+ assert fs.led is not None
+ fs.led.on.assert_called_once() # type: ignore[unionAttr]
+
+ def test_solid_brightness_does_not_scale_with_phase(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.SOLID)
+ _drive(v3_system.handler, _beat(beat_phase=0.9))
+ # SOLID must not scale — full color at any phase
+ fs.pixel.set_color.assert_called_once_with((100, 100, 100))
+
+ def test_solid_none_color_disables_pixel_and_led(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, None, LedDisplayStyle.SOLID)
+ _drive(v3_system.handler, _beat())
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+
+class TestMetronomeStyle:
+ def test_metronome_on_during_flash_window(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME)
+ _drive(v3_system.handler, _beat(beat_phase=0.9, is_flashing=True))
+ fs.pixel.set_color.assert_called_once_with((100, 100, 100))
+ fs.pixel.set_enable.assert_called_once_with(True)
+
+ def test_metronome_off_after_flash_window(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME)
+ _drive(v3_system.handler, _beat(beat_phase=0.0, is_flashing=False))
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+ def test_metronome_bar_start_uses_downbeat_color(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME)
+ _drive(v3_system.handler, _beat(beat_phase=0.5, is_bar_start=True, is_flashing=True))
+ fs.pixel.set_color.assert_called_once_with((100, 100, 100))
+
+ def test_metronome_unanchored_shows_steady_color(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME)
+ _drive(v3_system.handler, _beat(beat_phase=0.9, is_anchored=False))
+ fs.pixel.set_color.assert_called_once_with((100, 100, 100))
+
+
+class TestDefaultRendering:
+ """No bound plugin (or a bound plugin with no LedSpec) falls back to the
+ built-in toggle + category-color renderer."""
+
+ def test_unbound_untoggled_footswitch_is_off(self, v3_system: SystemFixture):
+ fs = v3_system.hw.footswitches[0]
+ fs.parameter = None
+ fs.toggled = False
+ fs.pixel = MagicMock()
+ fs.led = MagicMock()
+ _drive(v3_system.handler, _beat())
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+ def test_unbound_toggled_footswitch_shows_category_or_white(self, v3_system: SystemFixture):
+ fs = v3_system.hw.footswitches[0]
+ fs.parameter = None
+ fs.toggled = True
+ fs.category = None
+ fs.pixel = MagicMock()
+ fs.led = MagicMock()
+ _drive(v3_system.handler, _beat())
+ fs.pixel.set_color.assert_called_once_with((255, 255, 255))
+ fs.pixel.set_enable.assert_called_once_with(True)
+
+
+class TestPixelAndLedSameSource:
+ """Both fs.pixel and fs.led are written from the same renderer query in the
+ same driver call — no separate set_led path fighting the driver."""
+
+ def test_solid_on_lights_both_pixel_and_led(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (0, 255, 0), LedDisplayStyle.SOLID)
+ _drive(v3_system.handler, _beat())
+ fs.pixel.set_enable.assert_called_once_with(True)
+ assert fs.led is not None
+ fs.led.on.assert_called_once() # type: ignore[unionAttr]
+
+ def test_off_disables_both_pixel_and_led(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, None, LedDisplayStyle.SOLID)
+ _drive(v3_system.handler, _beat())
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+ def test_set_led_does_not_touch_hardware(self, v3_system: SystemFixture):
+ """set_led is a pure state update — it flips fs.toggled only. The next
+ driver tick renders the new state to both pixel and LED."""
+ fs = v3_system.hw.footswitches[0]
+ fs.pixel = MagicMock()
+ fs.led = MagicMock()
+ fs.set_led(True)
+ assert fs.toggled is True
+ fs.pixel.set_enable.assert_not_called()
+ fs.pixel.set_color.assert_not_called()
+ assert fs.led is not None
+ fs.led.on.assert_not_called() # type: ignore[unionAttr]
+ fs.led.off.assert_not_called() # type: ignore[unionAttr]
+ fs.led.blink.assert_not_called() # type: ignore[unionAttr]
+
+
+class TestDriverRunsInPollControls:
+ """The LED driver runs in poll_controls (10ms), not poll_indicators (20ms),
+ so a press and its LED update happen in the same tick."""
+
+ def test_poll_controls_drives_leds(self, v3_system: SystemFixture):
+ fs = _fs_with_frame(v3_system, (0, 255, 0), LedDisplayStyle.SOLID)
+ with patch.object(v3_system.hw, "poll_controls"):
+ v3_system.handler.poll_controls()
+ fs.pixel.set_color.assert_called_once_with((0, 255, 0))
+ fs.pixel.set_enable.assert_called_once_with(True)
+
+ def test_poll_indicators_does_not_drive_footswitch_leds(self, v3_system: SystemFixture):
+ """poll_indicators still drives hardware.indicators (VU meters) but no
+ longer drives footswitch LEDs — that moved to poll_controls."""
+ fs = _fs_with_frame(v3_system, (0, 255, 0), LedDisplayStyle.SOLID)
+ with patch.object(v3_system.hw, "poll_indicators"):
+ v3_system.handler.poll_indicators()
+ fs.pixel.set_color.assert_not_called()
+ fs.pixel.set_enable.assert_not_called()
diff --git a/tests/v3/test_footswitch_presets.py b/tests/v3/test_footswitch_presets.py
index 197cdf943..6d7901c85 100644
--- a/tests/v3/test_footswitch_presets.py
+++ b/tests/v3/test_footswitch_presets.py
@@ -9,15 +9,15 @@
match it against an unrelated plugin's MIDI-learned binding and steal
fs.parameter.
3. The label survives even if `fs.parameter` still ends up set by some
- other path -- defense in depth on top of (2), so
- `draw_footswitches`/`update_footswitch` never let a plugin/param name
- clobber a preset label.
+ other path -- defense in depth on top of (2), so
+ `draw_footswitches`/`update_footswitch` never let a plugin/param name
+ clobber a preset label.
4. The footswitch's LED/indicator lights only when its mapped snapshot is
the currently active one.
"""
import yaml
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo
from tests.types import SystemFixture
@@ -114,13 +114,21 @@ class TestPresetFootswitchIndicator:
def test_active_snapshot_footswitch_drives_physical_led(self, v3_system: SystemFixture):
"""A press never touches fs.toggled for preset footswitches (the
PresetEffect arm changes the snapshot, not fs.toggled), so the LCD
- redraw path is the only place that can also light the physical LED/pixel."""
+ redraw path is the only place that can also light the physical LED/pixel.
+
+ The per-tick LED driver (_drive_footswitch_leds) runs in poll_controls
+ and must light the pixel of the footswitch bound to the active preset.
+ Regression: stripping set_led/set_category's pixel calls left preset
+ footswitch pixels dark because preset switches never get a behavior
+ via ControllerManager.bind (no plugin parameter)."""
handler = v3_system.handler
hw = v3_system.hw
lcd = handler.lcd
fs0, fs1 = hw.footswitches[0], hw.footswitches[1]
fs0.pixel = MagicMock()
fs1.pixel = MagicMock()
+ fs0.led = MagicMock()
+ fs1.led = MagicMock()
fs0.add_preset(callback_arg=0)
fs1.add_preset(callback_arg=1)
handler.current.preset_index = 1
@@ -128,8 +136,16 @@ def test_active_snapshot_footswitch_drives_physical_led(self, v3_system: SystemF
lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches)
lcd.draw_main_panel()
- fs0.pixel.set_enable.assert_called_once_with(False)
- fs1.pixel.set_enable.assert_called_once_with(True)
+ # Drive one controls tick — the active preset's pixel must light.
+ # Patch hardware.poll_controls to skip the analog control refresh,
+ # which needs real SPI; we only want to exercise the handler's LED driver.
+ with patch.object(hw, "poll_controls"):
+ handler.poll_controls()
+
+ # fs1 is the active preset (index 1) → its pixel must be enabled.
+ fs1.pixel.set_enable.assert_called_with(True)
+ # fs0 is inactive → its pixel must be disabled.
+ fs0.pixel.set_enable.assert_called_with(False)
assert fs0.toggled is False
assert fs1.toggled is True
diff --git a/tests/v3/test_footswitch_state_view.py b/tests/v3/test_footswitch_state_view.py
new file mode 100644
index 000000000..213556f06
--- /dev/null
+++ b/tests/v3/test_footswitch_state_view.py
@@ -0,0 +1,255 @@
+"""LCD footswitch bar for a plugin that publishes a state (loopjefe).
+
+A looper track's switch is MIDI-learned to a `pprops:trigger` port
+(`advance`), so the bound parameter's name says nothing useful -- the slot has
+to name the *plugin* and show the plugin's own `state` output instead. Both
+come from the plugin's LedSpec, which is also what colors the physical LED, so
+the LCD and the hardware never disagree.
+"""
+
+from common.loop_progress import LoopFill, LoopProgress
+from common.parameter import Symbol, Type
+from modalapi.plugin import Plugin
+from modalapi.ws_protocol import BeatSyncMessage
+from plugins import lookup
+from pistomp.beatsync import FLASH_US, TickState
+from plugins.loopjefe import LOOPJEFE_URIS
+from tests.types import SystemFixture
+
+
+def _loopjefe(make_parameter, instance_id: str) -> Plugin:
+ advance = make_parameter("advance", instance_id, value=0.0)
+ advance.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl
+ uri = LOOPJEFE_URIS[0]
+ return Plugin(
+ instance_id,
+ {Symbol("advance"): advance},
+ {},
+ "Looper",
+ uri=uri,
+ customization=lookup(uri),
+ )
+
+
+def _bind(v3_system: SystemFixture, plugins: list[Plugin]) -> None:
+ """Learn each plugin's `advance` onto the footswitch of the same index."""
+ handler = v3_system.handler
+ assert handler.current
+ handler.current.pedalboard.plugins = plugins
+ for i, plugin in enumerate(plugins):
+ fs = v3_system.hw.footswitches[i]
+ binding = next(k for k, c in v3_system.hw.controllers.items() if c is fs)
+ channel, cc = binding.split(":")
+ v3_system.ws_bridge.inject(f"midi_map /graph/{plugin.instance_id} advance {channel} {cc} 0.0 1.0")
+ handler.poll_ws_messages()
+
+
+def test_two_track_looper_footswitch_bar(v3_system: SystemFixture, make_parameter, snapshot):
+ """Two looper tracks, each mid-flight in a different state."""
+ handler = v3_system.handler
+ lcd = handler.lcd
+ plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")]
+ _bind(v3_system, plugins)
+
+ lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches)
+ lcd.draw_main_panel()
+ snapshot("empty")
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 2.0") # Recording
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_2 state 4.0") # Playback
+ handler.poll_ws_messages()
+ lcd.update_footswitches()
+ snapshot("recording-and-playback")
+
+
+def test_state_view_falls_back_when_plugin_has_no_led_spec(v3_system: SystemFixture, make_parameter):
+ """A plugin without a LedSpec keeps the ordinary dot-and-label slot -- the
+ state view must not leak onto every bound footswitch."""
+ handler = v3_system.handler
+ assert handler.current
+
+ param = make_parameter("gain", "/Reverb", value=0.0)
+ plugin = Plugin("/Reverb", {Symbol("gain"): param}, {}, "Reverb")
+ handler.current.pedalboard.plugins = [plugin]
+
+ name, state, color, loop_icon = handler.lcd._footswitch_state(v3_system.hw.footswitches[0])
+ assert (name, state, color, loop_icon) == (None, None, None, False)
+
+
+def test_state_view_repaints_on_output_set(v3_system: SystemFixture, make_parameter, snapshot):
+ """The plugin's state arrives asynchronously over the socket, long after the
+ press that caused it. Without a repaint on `output_set` the slot keeps
+ whatever it painted at press time and the looper reads "Empty" forever."""
+ handler = v3_system.handler
+ lcd = handler.lcd
+ plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")]
+ _bind(v3_system, plugins)
+
+ lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches)
+ lcd.draw_main_panel()
+
+ # No press, no reload -- only the socket tells us the looper moved.
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 7.0") # Overdub
+ handler.poll_ws_messages()
+ snapshot("overdub-from-output-set")
+
+
+def _beat(
+ handler,
+ bar_phase: float,
+ beat_phase: float = 0.0,
+ is_bar_start: bool = False,
+ is_flashing: bool = True,
+) -> None:
+ """Drive one LED tick with a synthetic transport state."""
+ handler._drive_footswitch_leds(
+ TickState(
+ is_anchored=True,
+ is_flashing=is_flashing,
+ is_bar_start=is_bar_start,
+ bpm=120.0,
+ bpb=4.0,
+ beat_phase=beat_phase,
+ bar_phase=bar_phase,
+ )
+ )
+
+
+def test_progress_border_fills_by_bar_and_beat(v3_system: SystemFixture, make_parameter, snapshot):
+ """A 4-bar loop playing bar 3, half a bar in, fills 5/8 of the perimeter --
+ with a notch at each bar boundary. Loop 2 is stopped: full ring, no fill."""
+ handler = v3_system.handler
+ lcd = handler.lcd
+ plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")]
+ _bind(v3_system, plugins)
+
+ lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches)
+ lcd.draw_main_panel()
+
+ for inst, state in (("loopjefe_1", 4.0), ("loopjefe_2", 5.0)): # Playback, Stopped
+ v3_system.ws_bridge.inject(f"output_set /graph/{inst} state {state}")
+ v3_system.ws_bridge.inject(f"output_set /graph/{inst} loop_bars 4.0")
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0")
+ handler.poll_ws_messages()
+
+ _beat(handler, 0.5, is_flashing=True)
+ lcd.update_footswitches()
+ snapshot("play-bar3-beat3")
+
+
+def test_progress_border_chases_while_recording(v3_system: SystemFixture, make_parameter, snapshot):
+ """The first take has no length to be a fraction of, so the border sweeps a
+ head instead of filling. Loop 2 is empty: no border at all."""
+ handler = v3_system.handler
+ lcd = handler.lcd
+ plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")]
+ _bind(v3_system, plugins)
+
+ lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches)
+ lcd.draw_main_panel()
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 2.0") # Recording
+ handler.poll_ws_messages()
+
+ _beat(handler, 0.25, is_flashing=True)
+ lcd.update_footswitches()
+ snapshot("recording-chase")
+
+
+def test_progress_border_snapshots_at_integer_beat_syncs(v3_system: SystemFixture, make_parameter, snapshot):
+ """Capture each integer beat from the transport's synchronized clock."""
+ handler = v3_system.handler
+ lcd = handler.lcd
+ _bind(v3_system, [_loopjefe(make_parameter, "loopjefe_1")])
+
+ lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches)
+ lcd.draw_main_panel()
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0") # Playback
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0")
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0")
+ handler.poll_ws_messages()
+
+ anchor_us = 1_000_000
+ beat_period_us = 500_000
+ for beat_in_bar in range(4):
+ source_us = anchor_us + beat_in_bar * beat_period_us
+ v3_system.ws_bridge.inject(f"beat_sync {source_us} 120.0 4 {beat_in_bar}.0")
+ handler.poll_ws_messages()
+ state = handler.beat_grid.tick(source_us)
+ assert state.beat_phase == 0.0
+ assert state.bar_phase == beat_in_bar / 4.0
+ assert state.is_flashing is True
+ handler._drive_footswitch_leds(state)
+ lcd.update_footswitches()
+ snapshot(f"transport-beat-{beat_in_bar}-on")
+
+ cutoff = anchor_us + 3 * beat_period_us + FLASH_US
+ state = handler.beat_grid.tick(cutoff)
+ assert state.is_flashing is False
+ handler._drive_footswitch_leds(state)
+ lcd.update_footswitches()
+ snapshot("transport-beat-3-off-50000us")
+
+
+def test_overdub_past_declared_length_chases(v3_system: SystemFixture, make_parameter):
+ """An overdub that outruns the head loop's bar count has no denominator
+ left, so it degrades to the chaser rather than filling past 100%."""
+ handler = v3_system.handler
+ plugins = [_loopjefe(make_parameter, "loopjefe_1")]
+ _bind(v3_system, plugins)
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 7.0") # Overdub
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0")
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0")
+ handler.poll_ws_messages()
+ _beat(handler, 0.0)
+
+ fs = v3_system.hw.footswitches[0]
+ assert handler.footswitch_loop_progress(fs) == LoopProgress(LoopFill.FILL, (255, 140, 0), 4, 0.5)
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 4.0")
+ handler.poll_ws_messages()
+ _beat(handler, 0.0)
+ assert handler.footswitch_loop_progress(fs) == LoopProgress(LoopFill.CHASE, (255, 140, 0), 0, 0.0)
+
+
+def test_progress_border_pulses_with_the_beat(v3_system: SystemFixture, make_parameter):
+ """The border and physical LED share the binary transport flash state."""
+ handler = v3_system.handler
+ plugins = [_loopjefe(make_parameter, "loopjefe_1")]
+ _bind(v3_system, plugins)
+ fs = v3_system.hw.footswitches[0]
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0") # Playback
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0")
+ handler.poll_ws_messages()
+
+ _beat(handler, 0.0, beat_phase=0.0, is_bar_start=True, is_flashing=True)
+ during = handler.footswitch_loop_progress(fs)
+ _beat(handler, 0.2, beat_phase=0.8, is_flashing=False)
+ outside = handler.footswitch_loop_progress(fs)
+ assert during is not None and outside is not None
+ assert during.pulse == 1.0
+ assert outside.pulse == 0.0
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 5.0") # Stopped: steady
+ handler.poll_ws_messages()
+ _beat(handler, 0.2, beat_phase=0.8)
+ stopped = handler.footswitch_loop_progress(fs)
+ assert stopped is not None and stopped.pulse == 1.0
+
+
+def test_progress_border_is_steady_without_transport(v3_system: SystemFixture, make_parameter):
+ """Without transport, the border remains at its steady brightness."""
+ handler = v3_system.handler
+ plugins = [_loopjefe(make_parameter, "loopjefe_1")]
+ _bind(v3_system, plugins)
+
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0")
+ v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0")
+ handler.poll_ws_messages()
+ handler.beat_grid.clear()
+ handler._drive_footswitch_leds()
+
+ progress = handler.footswitch_loop_progress(v3_system.hw.footswitches[0])
+ assert progress is not None and progress.pulse == 1.0
diff --git a/tests/v3/test_hardware_config.py b/tests/v3/test_hardware_config.py
index 8bbc951f1..3bf51d570 100644
--- a/tests/v3/test_hardware_config.py
+++ b/tests/v3/test_hardware_config.py
@@ -195,3 +195,39 @@ def test_longpress_enum_covers_every_handler_callback(v3_system: SystemFixture):
# set_mod_tap_tempo shares the callback map but is reachable only via the
# `tap_tempo:` key, which passes a BPM no longpress can supply.
assert set(v3_system.handler.callbacks) - {"set_mod_tap_tempo"} == enum
+
+
+# ---------------------------------------------------------------------------
+# MIDI channel — the overlay must not clobber it
+# ---------------------------------------------------------------------------
+
+def test_overlay_without_midi_block_keeps_channel(v3_system: SystemFixture):
+ """A pedalboard config that says nothing about MIDI leaves the channel alone.
+
+ Resetting it re-keys every controller under "0:" while the parameter
+ bindings still read ":", so the switch stops dispatching and its
+ longpress goes out on the wrong channel.
+ """
+ hw = v3_system.hw
+ channel = hw.midi_channel
+ fs = hw.footswitches[0]
+ key = fs.dispatch_key
+
+ hw.reinit(_cfg(footswitches=[{"id": 0, "midi_CC": fs.midi_CC}]))
+
+ assert hw.midi_channel == channel
+ assert hw.footswitches[0].midi_channel == channel
+ assert hw.footswitches[0].dispatch_key == key
+ assert hw.controllers[key] is hw.footswitches[0]
+
+
+def test_overlay_may_still_override_midi_channel(v3_system: SystemFixture):
+ """An overlay that does declare a channel still wins (1-based in config)."""
+ hw = v3_system.hw
+
+ cfg = _cfg(footswitches=[{"id": 0, "midi_CC": 60}])
+ cfg[Token.HARDWARE][Token.MIDI] = {Token.CHANNEL: 5}
+ hw.reinit(cfg)
+
+ assert hw.midi_channel == 4
+ assert hw.footswitches[0].dispatch_key == "4:60"
diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py
index 6ea8e0cba..e2d03a0e5 100644
--- a/tests/v3/test_midi_learn.py
+++ b/tests/v3/test_midi_learn.py
@@ -14,6 +14,26 @@
}
+def test_trigger_uri_property_is_momentary():
+ """Raw LV2 property URIs still identify LoopJefe trigger ports."""
+ parameter = Parameter(
+ {
+ "symbol": "advance",
+ "shortName": "Advance",
+ "ranges": {"minimum": 0.0, "maximum": 1.0},
+ "properties": [
+ "http://lv2plug.in/ns/lv2core#integer",
+ "http://lv2plug.in/ns/ext/port-props#trigger",
+ ],
+ },
+ 0.0,
+ "0:60",
+ "loopjefe_1",
+ )
+
+ assert parameter.is_momentary is True
+
+
def _binding_for(hw, controller):
"""The 'channel:cc' key under which a controller is registered."""
return next(k for k, v in hw.controllers.items() if v is controller)
@@ -308,6 +328,210 @@ def test_v3_midi_learn_unknown_instance_is_ignored(v3_system: SystemFixture, mak
assert plugin.has_footswitch is False
+def _make_loopjefe_plugin_with_advance(_make_parameter, instance_id="loopjefe"):
+ """Build LoopJefe from the same LV2 trigger metadata mod-ui returns."""
+ from modalapi.plugin import Plugin
+ from plugins import lookup
+ from plugins.loopjefe import LOOPJEFE_URIS
+
+ advance = Parameter(
+ {
+ "symbol": "advance",
+ "shortName": "Advance",
+ "ranges": {"minimum": 0.0, "maximum": 1.0},
+ "properties": [
+ "http://lv2plug.in/ns/lv2core#integer",
+ "http://lv2plug.in/ns/ext/port-props#trigger",
+ ],
+ },
+ 0.0,
+ None,
+ instance_id,
+ )
+ uri = LOOPJEFE_URIS[0]
+ return Plugin(instance_id, {Symbol("advance"): advance}, {}, "Looper", uri=uri, customization=lookup(uri))
+
+
+class TestMidiLearnBindsMomentaryAndOutputs:
+ """Regression: the live MIDI-learn path (Handler._apply_midi_binding →
+ _bind_controller_to_param) must not need any plugin-specific input code —
+ momentary semantics come for free from the bound parameter's port type
+ (pprops:trigger → Type.TRIGGER), and the LED driver reads the plugin's own
+ generically-mirrored output_values (from its LedSpec), not anything cached
+ on the footswitch."""
+
+ def test_midi_learn_binds_trigger_parameter_as_momentary(self, v3_system: SystemFixture, make_parameter):
+ handler = v3_system.handler
+ hw = v3_system.hw
+ ws_bridge = v3_system.ws_bridge
+ assert handler.current
+
+ fs0 = hw.footswitches[0]
+ channel, cc = _binding_for(hw, fs0).split(":")
+
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ handler.current.pedalboard.plugins = [plugin]
+
+ ws_bridge.inject(f"midi_map /graph/loopjefe advance {channel} {cc} 0.0 1.0")
+ handler.poll_ws_messages()
+
+ assert fs0.parameter is plugin.parameters[Symbol("advance")]
+ assert fs0.parameter is not None
+ assert fs0.parameter.is_momentary is True, (
+ "advance is pprops:trigger — momentary must be derived from the "
+ "port type, with zero loopjefe-specific input code"
+ )
+
+ def test_momentary_press_emits_one_shot_127_every_press(
+ self, v3_system: SystemFixture, make_parameter
+ ):
+ """A pprops:trigger port fires on a rising edge only (loopjefe self-
+ clears the port). So every short-press must emit a fresh 127 — never
+ the 127/0 alternation a latching toggle produces, which would make the
+ looper advance on only every other press."""
+ from rtmidi.midiconstants import CONTROL_CHANGE
+ from pistomp.input.event import SwitchEvent, SwitchEventKind
+
+ handler = v3_system.handler
+ hw = v3_system.hw
+ ws_bridge = v3_system.ws_bridge
+ assert handler.current
+
+ fs0 = hw.footswitches[0]
+ channel, cc = _binding_for(hw, fs0).split(":")
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ handler.current.pedalboard.plugins = [plugin]
+ ws_bridge.inject(f"midi_map /graph/loopjefe advance {channel} {cc} 0.0 1.0")
+ handler.poll_ws_messages()
+
+ hw.midiout.send_message.reset_mock()
+ for _ in range(3):
+ handler.handle(SwitchEvent(controller=fs0, kind=SwitchEventKind.PRESS, timestamp=1.0))
+
+ sent = [c.args[0][2] for c in hw.midiout.send_message.call_args_list]
+ assert sent == [127, 127, 127], "momentary trigger must one-shot 127, not toggle 127/0"
+ assert all(c.args[0][1] == int(cc) for c in hw.midiout.send_message.call_args_list)
+ assert fs0.toggled is False, "a trigger has no on/off state to latch"
+
+ def test_momentary_longpress_reset_emits_one_shot_127(
+ self, v3_system: SystemFixture, make_parameter
+ ):
+ """A longpress raw-CC mapped to a pprops:trigger port (loopjefe reset)
+ is a one-shot too: every longpress emits 127, not the 127/0 toggle the
+ raw-CC path uses for ordinary (non-trigger) targets."""
+ from rtmidi.midiconstants import CONTROL_CHANGE
+ from common.parameter import Type
+ from pistomp.input.event import SwitchEvent, SwitchEventKind
+
+ handler = v3_system.handler
+ hw = v3_system.hw
+ assert handler.current
+
+ fs0 = hw.footswitches[0]
+ reset_cc = 64
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ reset = make_parameter("reset", "loopjefe", value=0.0)
+ reset.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl
+ reset.binding = f"{fs0.midi_channel}:{reset_cc}" # pedalboard-learned CC
+ plugin.parameters[Symbol("reset")] = reset
+ handler.current.pedalboard.plugins = [plugin]
+
+ fs0.longpress_action = {"midi_CC": reset_cc}
+ handler.bind_current_pedalboard()
+
+ hw.midiout.send_message.reset_mock()
+ event = SwitchEvent(controller=fs0, kind=SwitchEventKind.LONGPRESS, timestamp=1.0)
+ for _ in range(3):
+ handler.handle(event)
+
+ expected = [fs0.midi_channel | CONTROL_CHANGE, reset_cc, 127]
+ assert all(c.args[0] == expected for c in hw.midiout.send_message.call_args_list), (
+ "reset is pprops:trigger — every longpress must emit 127, not toggle 127/0"
+ )
+
+ def test_longpress_toggle_target_flips_through_reactive_layer(
+ self, v3_system: SystemFixture, make_parameter
+ ):
+ """A longpress raw-CC resolving to a loaded *non*-trigger param toggles
+ it through the reactive layer: each longpress flips the param and emits
+ its bound CC as an alternating 127/0 edge — no local _longpress_cc_state,
+ so the toggle tracks the param's real value."""
+ from rtmidi.midiconstants import CONTROL_CHANGE
+ from pistomp.input.event import SwitchEvent, SwitchEventKind
+
+ handler = v3_system.handler
+ hw = v3_system.hw
+ assert handler.current
+
+ fs0 = hw.footswitches[0]
+ toggle_cc = 64
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ solo = make_parameter("solo", "loopjefe", value=0.0) # non-trigger toggle
+ solo.binding = f"{fs0.midi_channel}:{toggle_cc}"
+ plugin.parameters[Symbol("solo")] = solo
+ handler.current.pedalboard.plugins = [plugin]
+
+ fs0.longpress_action = {"midi_CC": toggle_cc}
+ handler.bind_current_pedalboard()
+
+ hw.midiout.send_message.reset_mock()
+ event = SwitchEvent(controller=fs0, kind=SwitchEventKind.LONGPRESS, timestamp=1.0)
+ for _ in range(2):
+ handler.handle(event)
+
+ sent = [c.args[0][2] for c in hw.midiout.send_message.call_args_list]
+ assert sent == [127, 0], "a loaded toggle target alternates 127/0 on its bound CC"
+ assert all(
+ c.args[0][:2] == [fs0.midi_channel | CONTROL_CHANGE, toggle_cc]
+ for c in hw.midiout.send_message.call_args_list
+ )
+ assert solo.value == 0.0, "two flips return the param to rest"
+
+ def test_update_interesting_outputs_derives_from_plugin_led_spec(
+ self, v3_system: SystemFixture, make_parameter
+ ):
+ """Monitored outputs are owned by the plugin (its LedSpec), not by
+ whichever footswitch happens to be bound to it."""
+ handler = v3_system.handler
+ assert handler.current
+
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ handler.current.pedalboard.plugins = [plugin]
+
+ handler._update_interesting_outputs()
+
+ last = v3_system.ws_bridge.interesting_calls[-1]
+ assert "loopjefe/state" in last
+ assert "loopjefe/measure_number" in last
+
+ def test_output_set_updates_plugin_output_values_for_led_spec(
+ self, v3_system: SystemFixture, make_parameter
+ ):
+ """End-to-end: an output_set for loopjefe/state and measure_number
+ updates plugin.output_values generically, and the plugin's LedSpec
+ renders the right color/style from them — no footswitch involved."""
+ from modalapi.led_render import LedDisplayStyle, render_led_spec
+
+ handler = v3_system.handler
+ ws_bridge = v3_system.ws_bridge
+ assert handler.current
+
+ plugin = _make_loopjefe_plugin_with_advance(make_parameter)
+ handler.current.pedalboard.plugins = [plugin]
+
+ ws_bridge.inject("output_set /graph/loopjefe state 2.0")
+ ws_bridge.inject("output_set /graph/loopjefe measure_number 1.0")
+ handler.poll_ws_messages()
+
+ assert plugin.output_values["state"] == 2.0
+ assert plugin.output_values["measure_number"] == 1.0
+
+ assert plugin.customization.led_spec is not None
+ color, style = render_led_spec(plugin.customization.led_spec, plugin.output_values)
+ assert color == (255, 0, 0) # Recording → red
+ assert style == LedDisplayStyle.METRONOME
+
+
def test_v3_midi_learn_adds_table_row_for_encoder(v3_system: SystemFixture, make_plugin, make_parameter):
"""A midi_map for an encoder's CC adds a ParamEffect ROTATE row to the
pedalboard layer so _handle_encoder dispatch and badges reflect the
diff --git a/tests/v3/test_reactive_parameter.py b/tests/v3/test_reactive_parameter.py
index 127708090..7d56fcfa6 100644
--- a/tests/v3/test_reactive_parameter.py
+++ b/tests/v3/test_reactive_parameter.py
@@ -197,6 +197,33 @@ def test_settled_fires_on_reconcile_and_commit_not_preview():
assert settled == [130.0, 140.0] # rolled back — did not settle
+def test_pulse_emits_one_edge_and_self_clears():
+ """A pprops:trigger pulse drives the value to its "on" edge, publishes that
+ single edge through the sink, then self-clears to rest — a footswitch CC
+ reads 127 once and the port never latches."""
+ info: PortInfo = {"shortName": "adv", "symbol": "advance", "ranges": {"minimum": 0, "maximum": 1}}
+ p = Parameter(info, 0.0, None, "inst")
+ sent: list[float] = []
+ p.pulse(lambda param: sent.append(param.value) or True)
+
+ assert sent == [1.0], "the sink sees the held 'on' edge"
+ assert p.value == 0.0, "a trigger persists no value — it self-clears to rest"
+
+
+def test_pulse_settles_at_rest_not_at_the_edge():
+ """subscribe_settled fires once, at the cleared rest value — a bound
+ footswitch keycap that mirrors settled state never latches 'on'."""
+ info: PortInfo = {"shortName": "adv", "symbol": "advance", "ranges": {"minimum": 0, "maximum": 1}}
+ p = Parameter(info, 0.0, None, "inst")
+ settled: list[float] = []
+ p.subscribe_settled(lambda param: settled.append(param.value))
+
+ p.pulse(lambda param: True)
+ p.pulse(lambda param: True)
+
+ assert settled == [0.0, 0.0], "each pulse settles once, at rest"
+
+
def test_subscribe_returns_unsubscriber():
"""The returned callable tears down the subscription."""
info: PortInfo = {"shortName": "x", "symbol": "x", "ranges": {"minimum": 0, "maximum": 1}}
diff --git a/tests/v3/test_taptempo_led.py b/tests/v3/test_taptempo_led.py
new file mode 100644
index 000000000..d7e779226
--- /dev/null
+++ b/tests/v3/test_taptempo_led.py
@@ -0,0 +1,170 @@
+"""Taptempo footswitch LED — two metronome sources, one driver.
+
+The taptempo footswitch's LED flashes from whichever beat source is active:
+ - Transport anchored (beat_sync received): beat_grid drives the flash,
+ white on downbeat, grey on beat.
+ - Taptempo only (no beat_sync, but taptempo enabled with bpm): the LED
+ blinks from taptempo.anchor + bpm — on for the first 50ms of each beat
+ period, off otherwise.
+ - Taptempo disabled: the footswitch behaves as a default toggle — no
+ metronome flash, whether or not the transport is anchored.
+
+The gpiozero hardware blink() is gone — the 10ms driver tick computes on/off
+from the taptempo phase, same as it does for the transport-anchored case.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock, patch
+
+from modalapi.modhandler import _METRONOME_BEAT_RGB, _METRONOME_DOWNBEAT_RGB
+from pistomp.beatsync import TickState
+from tests.types import SystemFixture
+
+
+def _find_taptempo_fs(v3_system: SystemFixture):
+ for fs in v3_system.hw.footswitches:
+ if fs.taptempo is not None:
+ return fs
+ raise AssertionError("No taptempo footswitch in v3 fixture")
+
+
+def _mock_fs(fs):
+ fs.pixel = MagicMock()
+ fs.led = MagicMock()
+
+
+def _enable_tap(fs):
+ assert fs.taptempo is not None
+ fs.taptempo.enable(True)
+
+
+class TestTransportAnchored:
+ """When beat_grid is anchored (beat_sync received) and tap tempo mode is on,
+ the taptempo footswitch flashes beat-synced from the transport — same as the
+ old _drive_metronome."""
+
+ def test_flashing_beat_shows_beat_color(self, v3_system: SystemFixture):
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ _enable_tap(fs)
+ beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0)
+ v3_system.handler._drive_footswitch_leds(beat)
+ fs.pixel.set_color.assert_called_once_with(_METRONOME_BEAT_RGB)
+ fs.pixel.set_enable.assert_called_once_with(True)
+ assert fs.led is not None
+ fs.led.on.assert_called_once() # type: ignore[unionAttr]
+
+ def test_flashing_bar_start_shows_downbeat_color(self, v3_system: SystemFixture):
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ _enable_tap(fs)
+ beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=True, bpm=120.0, bpb=4.0, beat_phase=0.0)
+ v3_system.handler._drive_footswitch_leds(beat)
+ fs.pixel.set_color.assert_called_once_with(_METRONOME_DOWNBEAT_RGB)
+
+ def test_not_flashing_turns_off(self, v3_system: SystemFixture):
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ _enable_tap(fs)
+ beat = TickState(is_anchored=True, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.5)
+ v3_system.handler._drive_footswitch_leds(beat)
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+
+class TestTaptempoBlink:
+ """When beat_grid is NOT anchored but taptempo is enabled with a bpm, the
+ LED blinks from taptempo.anchor + bpm — computed by the 10ms driver, not
+ gpiozero.blink()."""
+
+ def test_taptempo_blink_on_within_flash_window(self, v3_system: SystemFixture):
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.enable(True)
+ fs.taptempo.set_bpm(120.0) # 120bpm → 500ms period, 50ms on-window
+ fs.taptempo.anchor = 1000.0 # last tap at t=1000.0
+
+ # Now=1000.025 → 25ms into the beat → within the 50ms on-window → ON
+ with patch("modalapi.modhandler._now_us", return_value=int(1000.025 * 1_000_000)):
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0)
+ )
+ fs.pixel.set_enable.assert_called_once_with(True)
+ assert fs.led is not None
+ fs.led.on.assert_called_once() # type: ignore[unionAttr]
+
+ def test_taptempo_blink_off_outside_flash_window(self, v3_system: SystemFixture):
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.set_bpm(120.0) # 500ms period, 50ms on-window
+ fs.taptempo.anchor = 1000.0
+
+ # Now=1000.3 → 300ms into the beat → past the 50ms on-window → OFF
+ with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)):
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0)
+ )
+ fs.pixel.set_enable.assert_called_once_with(False)
+ assert fs.led is not None
+ fs.led.off.assert_called_once() # type: ignore[unionAttr]
+
+ def test_taptempo_zero_bpm_does_not_blink(self, v3_system: SystemFixture):
+ """No taps yet (bpm=0) → no blink; fall through to default behavior."""
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.enable(True)
+ fs.taptempo.set_bpm(0.0)
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=0.0, bpb=4.0, beat_phase=0.0)
+ )
+ # No blink — the default behavior takes over (off when not toggled)
+ fs.pixel.set_enable.assert_called_once_with(False)
+
+ def test_taptempo_disabled_falls_through_to_default(self, v3_system: SystemFixture):
+ """Taptempo disabled → the footswitch is a normal toggle; the driver
+ renders from the default behavior (toggled + category color)."""
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.enable(False)
+ fs.toggled = True
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=0.0, bpb=4.0, beat_phase=0.0)
+ )
+ # Default behavior: toggled=True → pixel on with category color
+ fs.pixel.set_enable.assert_called_once_with(True)
+
+ def test_no_gpiozero_blink_called(self, v3_system: SystemFixture):
+ """Regression: the gpiozero hardware blink() must not be called — the
+ driver computes on/off from the taptempo phase at 10ms granularity."""
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.enable(True)
+ fs.taptempo.set_bpm(120.0)
+ fs.taptempo.anchor = 1000.0
+ with patch("modalapi.modhandler._now_us", return_value=int(1000.05 * 1_000_000)):
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0)
+ )
+ assert fs.led is not None
+ fs.led.blink.assert_not_called() # type: ignore[unionAttr]
+
+
+def test_anchored_transport_does_not_flash_when_disabled(v3_system: SystemFixture):
+ """Transport anchored but tap tempo mode off → no metronome flash; the
+ switch renders from its own binding like any other."""
+ fs = _find_taptempo_fs(v3_system)
+ _mock_fs(fs)
+ assert fs.taptempo is not None
+ fs.taptempo.enable(False)
+ fs.toggled = True
+ v3_system.handler._drive_footswitch_leds(
+ TickState(is_anchored=True, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.5)
+ )
+ fs.pixel.set_enable.assert_called_once_with(True) # the flash would have blanked it
diff --git a/uilib/__init__.py b/uilib/__init__.py
index 98bdb7cb3..042dcad4c 100644
--- a/uilib/__init__.py
+++ b/uilib/__init__.py
@@ -37,6 +37,7 @@
"PanelStack",
"Parameterdialog",
"PluginTile",
+ "LoopPluginTile",
"RoundedPanel",
"ScrollingText",
"ShroudedPanel",
@@ -80,6 +81,6 @@
)
from uilib.panel import LcdBase, Panel, PanelDecorator, PanelStack, RoundedPanel, ShroudedPanel
from uilib.parameterdialog import Parameterdialog
-from uilib.text import Button, LetterSelector, PluginTile, ScrollingText, TextEditor, TextWidget
+from uilib.text import Button, LetterSelector, LoopPluginTile, PluginTile, ScrollingText, TextEditor, TextWidget
from uilib.widget import Widget
diff --git a/uilib/footswitch.py b/uilib/footswitch.py
index 69eb65248..df498afdd 100644
--- a/uilib/footswitch.py
+++ b/uilib/footswitch.py
@@ -23,9 +23,11 @@
import pygame
+from common.loop_progress import LoopFill, LoopProgress
from uilib.box import Box
from uilib.config import Color, Config
-from uilib.glyphs import CircleGlyph, RingGlyph
+from uilib.glyphs import CircleGlyph, LoopIconGlyph, RingGlyph
+from uilib.glyphs.perimeter_progress import PerimeterProgressGlyph
from uilib.glyphs.tint import tint_mask
from uilib.misc import InputEvent, get_text_size
from uilib.paint import PaintContext
@@ -61,6 +63,17 @@ def get_bpm(self) -> float: ...
# Title white — same (255,255,255) used for pedalboard/snapshot titles.
TITLE_WHITE: Color = (255, 255, 255)
+# Loop progress border: inset 1px all round, same weight as the tap border.
+PROGRESS_INSET = 1
+PROGRESS_RADIUS = 5
+PROGRESS_THICKNESS = 2.0
+PROGRESS_GAP = 3.0 # notch between bars, px of arclength
+CHASE_SPAN = 0.18 # turns of perimeter the indeterminate head covers
+PULSE_STEPS = 8 # brightness quantisation; each step past this is a slot repaint
+TAP_FLASH_SECONDS = 0.05
+# The unfilled track, as a fraction of the state colour.
+TRACK_DIM = 0.28
+
class FootswitchWidget(Widget):
"""Footswitch indicator: a colored dot (the "LED") with a label below,
@@ -88,6 +101,11 @@ class FootswitchWidget(Widget):
_TAP_Y_LABEL = 2 # "TAP" header (14pt Bold)
_TAP_Y_BPM = 17 # BPM digits (16pt Bold)
+ # Two-line state view, same rhythm as the tap view.
+ _STATE_Y_NAME = 2
+ _STATE_Y_STATE = 17
+ _STATE_FONT_SIZE = 13
+
font: pygame._freetype.Font
small_font: pygame._freetype.Font | None
label: str | None
@@ -95,7 +113,11 @@ class FootswitchWidget(Widget):
num: int | None
is_bypassed: bool
taptempo: TapTempoProtocol | None
+ state_label: str | None
+ progress_fn: Callable[[], LoopProgress | None] | None
+ loop_icon: bool
_pulse_on: bool
+ _progress: LoopProgress | None
def __init__(
self,
@@ -105,6 +127,9 @@ def __init__(
is_bypassed: bool,
small_font: pygame._freetype.Font | None = None,
taptempo: TapTempoProtocol | None = None,
+ state_label: str | None = None,
+ progress_fn: Callable[[], LoopProgress | None] | None = None,
+ loop_icon: bool = False,
**kwargs,
):
self._init_attrs(Widget.INH_ATTRS, kwargs)
@@ -116,7 +141,12 @@ def __init__(
self.num = None
self.is_bypassed = is_bypassed
self.taptempo = taptempo
+ self.state_label = state_label
+ self.progress_fn = progress_fn
+ self.loop_icon = loop_icon
self._pulse_on = True
+ self._progress = None
+ self._progress_key: tuple[int, int, int, int] | None = None
def _tap_active(self) -> bool:
return self.taptempo is not None and self.taptempo.is_enabled()
@@ -154,7 +184,9 @@ def _draw(self, ctx: PaintContext) -> None:
is_on = not self.is_bypassed
has_label = bool(self.label)
- if has_label:
+ if self.state_label is not None:
+ self._draw_state(ctx, w)
+ elif has_label:
self._draw_dot_and_label(ctx, w, is_on)
else:
self._draw_letter_badge(ctx, w, is_on)
@@ -180,6 +212,49 @@ def _draw_tap(self, ctx: PaintContext) -> None:
dw, _ = get_text_size(digits, bpm_font)
ctx.draw_text(((w - dw) // 2, self._TAP_Y_BPM), digits, fill=self.TAP_BPM_COLOR, font=bpm_font)
+ def _draw_state(self, ctx: PaintContext, w: int) -> None:
+ """Two-line view for a switch whose plugin publishes a state."""
+ self._draw_progress(ctx, w, ctx.height)
+ name_font = self._slot_font()
+ state_font = Config().get_font("footswitch_badge")
+
+ if self.loop_icon:
+ self._draw_loop_name(ctx, w, name_font)
+ else:
+ name = self._fit(self.label or "", w - 2, name_font)
+ nw, _ = get_text_size(name, name_font)
+ ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=name_font)
+
+ state = self._fit((self.state_label or "").upper(), w - 2, state_font)
+ sw, _ = get_text_size(state, state_font, self._STATE_FONT_SIZE)
+ fill = self.color if self.color is not None else self.BOUND_OFF_LABEL
+ ctx.draw_text(
+ ((w - sw) // 2, self._STATE_Y_STATE + 1),
+ state,
+ fill=fill,
+ font=state_font,
+ size=self._STATE_FONT_SIZE,
+ )
+
+ def _draw_loop_name(self, ctx: PaintContext, w: int, font: "pygame._freetype.Font") -> None:
+ """Render the racetrack glyph + track number instead of 'Loop N' text."""
+ import re
+
+ label = self.label or ""
+ m = re.search(r"\d+$", label)
+ num_str = m.group() if m else ""
+ glyph = LoopIconGlyph() # 48×14 default; module-level cache makes this free
+ gap = 4
+ nw, _ = get_text_size(num_str, font) if num_str else (0, 0)
+ total_w = glyph.width + (gap + nw if num_str else 0)
+ gx = (w - total_w) // 2
+ # Vertically centre the 14px glyph in the 15px name row (y=2..16).
+ gy = self._STATE_Y_NAME + (15 - glyph.height) // 2 + 3
+ ox, oy = ctx._f().topleft
+ ctx.surface.blit(tint_mask(glyph.render(), self.BOUND_OFF_LABEL), (gx + ox, gy + oy))
+ if num_str:
+ ctx.draw_text((gx + glyph.width + gap, self._STATE_Y_NAME), num_str, fill=self.BOUND_OFF_LABEL, font=font)
+
def _draw_dot_and_label(self, ctx: PaintContext, w: int, is_on: bool) -> None:
"""Small dot on top, label centered below."""
cx = w // 2
@@ -233,10 +308,79 @@ def refresh(self, box=None):
else:
super().refresh(box)
+ def _progress_glyph(self, w: int, h: int) -> PerimeterProgressGlyph:
+ return PerimeterProgressGlyph(
+ w - 2 * PROGRESS_INSET, h - 2 * PROGRESS_INSET, PROGRESS_RADIUS, PROGRESS_THICKNESS
+ )
+
+ def _draw_progress(self, ctx: PaintContext, w: int, h: int) -> None:
+ """The loop's position around the slot's border: one arc per bar, the
+ elapsed part in the state colour over a dim track of the same hue."""
+ progress = self._progress
+ if progress is None or w <= 2 * PROGRESS_RADIUS or h <= 2 * PROGRESS_RADIUS:
+ return
+
+ glyph = self._progress_glyph(w, h)
+ ox, oy = ctx._f().topleft
+ at = (PROGRESS_INSET + ox, PROGRESS_INSET + oy)
+ r, g, b = progress.color
+ # Only the lit part carries the beat envelope -- a track that breathed
+ # with it would read as the whole slot flickering.
+ lit: Color = (int(r * progress.pulse), int(g * progress.pulse), int(b * progress.pulse))
+ dim: Color = (int(r * TRACK_DIM), int(g * TRACK_DIM), int(b * TRACK_DIM))
+
+ if progress.mode is LoopFill.STATIC:
+ ctx.surface.blit(tint_mask(glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP), dim), at)
+ return
+
+ if progress.mode is LoopFill.CHASE:
+ head = glyph.render(progress.position, progress.position + CHASE_SPAN)
+ ctx.surface.blit(tint_mask(head, lit), at)
+ return
+
+ ctx.surface.blit(tint_mask(glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP), dim), at)
+ filled = glyph.render(0.0, progress.position, progress.segments, PROGRESS_GAP)
+ ctx.surface.blit(tint_mask(filled, lit), at)
+
+ def poll_progress(self) -> bool:
+ """Re-read the loop position; True when the drawn result would differ.
+
+ Quantised to whole perimeter pixels — the position advances
+ continuously but the border can only move a pixel at a time, and each
+ step costs a slot repaint."""
+ if self.progress_fn is None:
+ return False
+ progress = self.progress_fn()
+ if progress is None:
+ changed = self._progress is not None
+ self._progress, self._progress_key = None, None
+ return changed
+
+ box = self.box
+ w = box.width if box is not None else 0
+ h = box.height if box is not None else 0
+ if w <= 2 * PROGRESS_RADIUS or h <= 2 * PROGRESS_RADIUS:
+ return False
+ steps = self._progress_glyph(w, h).perimeter
+ key = (
+ progress.mode.value,
+ progress.segments,
+ int(progress.position * steps),
+ int(progress.pulse * PULSE_STEPS),
+ )
+
+ self._progress = progress
+ if key == self._progress_key:
+ return False
+ self._progress_key = key
+ return True
+
def tick(self) -> None:
"""Blink the tap border at tempo, phase-locked to the last tap."""
taptempo = self.taptempo
if taptempo is None or not taptempo.is_enabled():
+ if self.poll_progress():
+ self.refresh()
return
bpm = taptempo.get_bpm()
if not bpm:
@@ -247,7 +391,7 @@ def tick(self) -> None:
return
period = 60.0 / bpm
phase = (time.monotonic() - taptempo.anchor) % period
- on = phase < period / 4
+ on = phase < TAP_FLASH_SECONDS
if on != self._pulse_on:
self._pulse_on = on
self.refresh()
diff --git a/uilib/glyphs/__init__.py b/uilib/glyphs/__init__.py
index 1eee0d841..09cad8c93 100644
--- a/uilib/glyphs/__init__.py
+++ b/uilib/glyphs/__init__.py
@@ -30,6 +30,7 @@
from uilib.glyphs.expression_pedal import ExpressionPedalGlyph
from uilib.glyphs.keycap_corner import KeycapCornerGlyph
from uilib.glyphs.knob import KnobGlyph
+from uilib.glyphs.loop_icon import LoopIconGlyph
from uilib.glyphs.outline import render_rounded_fill, render_rounded_outline
from uilib.glyphs.pill import PillGlyph
from uilib.glyphs.rounded_rect import RoundedRectGlyph, render_rounded_mask
@@ -50,7 +51,7 @@
"PillGlyph",
"RectBorder",
"RingGlyph",
- "RoundedRectGlyph",
+ "LoopIconGlyph",
"SignalBarsGlyph",
"SpinnerGlyph",
"render_rounded_fill",
diff --git a/uilib/glyphs/loop_icon.py b/uilib/glyphs/loop_icon.py
new file mode 100644
index 000000000..f8cb98c5f
--- /dev/null
+++ b/uilib/glyphs/loop_icon.py
@@ -0,0 +1,122 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Loop-track icon: a horizontal pill/racetrack outline with two staggered arrowheads.
+
+The top arrowhead (→) sits right-of-centre and the bottom (←) sits left-of-centre,
+suggesting two runners chasing each other clockwise around the oval.
+
+Rendering pipeline: PIL 4× supersampling + LANCZOS downscale for analytic-quality
+AA at small sizes. Returns a white SRCALPHA pygame surface (alpha = coverage);
+callers tint it with tint_mask().
+"""
+
+from __future__ import annotations
+
+from functools import lru_cache
+
+import numpy as np
+import pygame
+from PIL import Image, ImageDraw
+
+
+@lru_cache(maxsize=8)
+def _render(width: int, height: int) -> pygame.Surface:
+ s = 8 # supersampling factor
+ W, H = width * s, height * s
+
+ # Stroke width ≈ 2/14 of final height, rounded to even at scale.
+ sw = max(s * 2, round(height * s / 7) // 2 * 2)
+
+ # Inset so the outer stroke edge doesn't clip at the canvas boundary.
+ pad = sw // 2 + s
+
+ big = Image.new("L", (W, H), 0)
+ bd = ImageDraw.Draw(big)
+
+ # Stadium (pill) outline — full-semicircle caps.
+ inner_h = H - 2 * pad
+ bd.rounded_rectangle(
+ (pad, pad, W - pad, H - pad),
+ radius=inner_h // 2,
+ outline=255,
+ width=sw,
+ )
+
+ mid_x = W // 2
+ top_y = pad + sw // 2 # centreline of top stroke
+ bot_y = H - pad - sw // 2 # centreline of bottom stroke
+ al = (sw * 3) // 4 # arrow half-length (base → tip)
+ ab = round(sw * 1.5) # arrow half-base — wider than track for visibility
+ gap = s * 3 # gap between arrowhead tip and resuming track
+ offset = W // 8 # stagger: top arrow right, bottom arrow left
+
+ top_cx = mid_x + offset
+ bot_cx = mid_x - offset
+
+ # Top → : base at (top_cx - al), tip at (top_cx + al).
+ # Erase from the base rightward; base side stays flush with incoming track.
+ bd.rectangle((top_cx - al, 0, top_cx + al + gap, pad + sw + s), fill=0)
+ bd.polygon(
+ [(top_cx - al, top_y - ab), (top_cx - al, top_y + ab), (top_cx + al, top_y)],
+ fill=255,
+ )
+
+ # Bottom ← : base at (bot_cx + al), tip at (bot_cx - al).
+ # Erase from the base leftward; base side stays flush with incoming track.
+ bd.rectangle((bot_cx - al - gap, H - pad - sw - s, bot_cx + al, H), fill=0)
+ bd.polygon(
+ [(bot_cx + al, bot_y - ab), (bot_cx + al, bot_y + ab), (bot_cx - al, bot_y)],
+ fill=255,
+ )
+
+ # Downscale to target size with LANCZOS for sub-pixel sharpness.
+ mask = big.resize((width, height), Image.Resampling.LANCZOS)
+
+ # White SRCALPHA surface — tint_mask() handles colourisation at blit time.
+ mask_arr = np.frombuffer(mask.tobytes(), dtype=np.uint8).reshape((height, width)).T
+ surf = pygame.Surface((width, height), pygame.SRCALPHA)
+ pix = pygame.surfarray.pixels3d(surf)
+ alp = pygame.surfarray.pixels_alpha(surf)
+ pix[:, :, :] = 255
+ alp[:, :] = mask_arr
+ del pix, alp
+ return surf
+
+
+class LoopIconGlyph:
+ """Racetrack loop icon: a wider-than-tall pill outline with two chase arrows.
+
+ Renders as a white alpha mask; use tint_mask() to colourise before blitting.
+ Geometry is cached at module level on (width, height) — multiple widget
+ instances sharing the same size pay only one render.
+ """
+
+ def __init__(self, width: int = 42, height: int = 14) -> None:
+ self._width = width
+ self._height = height
+
+ @property
+ def width(self) -> int:
+ return self._width
+
+ @property
+ def height(self) -> int:
+ return self._height
+
+ def render(self) -> pygame.Surface:
+ return _render(self._width, self._height)
diff --git a/uilib/glyphs/perimeter_progress.py b/uilib/glyphs/perimeter_progress.py
new file mode 100644
index 000000000..65a6f686e
--- /dev/null
+++ b/uilib/glyphs/perimeter_progress.py
@@ -0,0 +1,140 @@
+# SPDX-License-Identifier: AGPL-3.0-or-later
+#
+# This file is part of pi-stomp.
+#
+# pi-stomp is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# pi-stomp is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with pi-stomp. If not, see .
+
+"""Chamfered perimeter used as a progress track.
+
+Renders an **alpha mask** (white RGB, coverage in alpha) of the polygonal path
+from `start` to `end` measured as arclength around a chamfered rectangle,
+clockwise from top-centre. `segments` notches the track at each 1/n boundary,
+so a 4-bar loop reads as four angular arcs rather than a rounded box.
+
+The path is an analytic distance field over straight vector edges. Geometry is
+cached per (size, chamfer, thickness); `render` is not, because a progress
+sweep visits every pixel step.
+
+Unlike the disc glyphs this is sized to a box, not a radius: blit at the box's
+top-left, no centring offset.
+"""
+
+from __future__ import annotations
+
+import math
+from functools import lru_cache
+
+import numpy as np
+import pygame
+
+
+@lru_cache(maxsize=16)
+def _geometry(width: int, height: int, radius: int, thickness: float) -> tuple[np.ndarray, np.ndarray, float]:
+ """Return coverage, normalized path position, and polygon perimeter."""
+ w = float(width)
+ h = float(height)
+ chamfer = min(max(float(radius), 0.0), w / 2.0, h / 2.0)
+ points = (
+ (w / 2.0, 0.0),
+ (w - chamfer, 0.0),
+ (w, chamfer),
+ (w, h - chamfer),
+ (w - chamfer, h),
+ (chamfer, h),
+ (0.0, h - chamfer),
+ (0.0, chamfer),
+ (chamfer, 0.0),
+ )
+
+ x = np.arange(width, dtype=float) + 0.5
+ y = np.arange(height, dtype=float) + 0.5
+ X, Y = np.meshgrid(x, y)
+ best = np.full((height, width), np.inf)
+ position = np.zeros((height, width), dtype=float)
+ offset = 0.0
+
+ for start, end in zip(points, points[1:] + points[:1]):
+ x0, y0 = start
+ x1, y1 = end
+ dx = x1 - x0
+ dy = y1 - y0
+ length = math.hypot(dx, dy)
+ if length == 0.0:
+ continue
+ u = np.clip(((X - x0) * dx + (Y - y0) * dy) / (length * length), 0.0, 1.0)
+ nearest_x = x0 + u * dx
+ nearest_y = y0 + u * dy
+ distance = np.hypot(X - nearest_x, Y - nearest_y)
+ closer = distance < best
+ best = np.where(closer, distance, best)
+ position = np.where(closer, offset + u * length, position)
+ offset += length
+
+ coverage = np.clip(thickness / 2.0 + 0.5 - best, 0.0, 1.0)
+ perimeter = offset
+ return coverage, np.mod(position / perimeter, 1.0), perimeter
+
+
+class PerimeterProgressGlyph:
+ """Progress track around a chamfered rectangle.
+
+ `render()` returns an alpha mask the size of the box; blit it at the box's
+ top-left.
+ """
+
+ def __init__(self, width: int, height: int, radius: int, thickness: float = 2.0) -> None:
+ self._width = int(width)
+ self._height = int(height)
+ self._radius = int(radius)
+ self._thickness = float(thickness)
+
+ @property
+ def perimeter(self) -> float:
+ """Path length in pixels — the natural quantum for a progress step."""
+ return _geometry(self._width, self._height, self._radius, self._thickness)[2]
+
+ def render(self, start: float, end: float, segments: int = 0, gap: float = 3.0) -> pygame.Surface:
+ """Mask of the arc [start, end] in turns, wrapping past 1.
+
+ `segments` > 1 opens a `gap`-pixel notch at every 1/segments boundary.
+ """
+ coverage, t, perimeter = _geometry(self._width, self._height, self._radius, self._thickness)
+
+ span = end - start
+ if span >= 1.0:
+ select = np.ones_like(t)
+ elif span <= 0.0:
+ select = np.zeros_like(t)
+ else:
+ behind = np.mod(t - start, 1.0)
+ select = np.clip((span - behind) * perimeter + 0.5, 0.0, 1.0)
+ if start != 0.0:
+ select = select * np.clip(behind * perimeter + 0.5, 0.0, 1.0)
+
+ if segments > 1:
+ to_boundary = np.abs(np.mod(t * segments + 0.5, 1.0) - 0.5) * perimeter / segments
+ select = select * np.clip(to_boundary - gap / 2.0 + 0.5, 0.0, 1.0)
+
+ alpha = np.clip(coverage * select * 255.0, 0.0, 255.0).astype(np.uint8)
+
+ surf = pygame.Surface((self._width, self._height), pygame.SRCALPHA)
+ pixels = pygame.surfarray.pixels3d(surf)
+ pixels[:, :, 0] = 255
+ pixels[:, :, 1] = 255
+ pixels[:, :, 2] = 255
+ del pixels
+ pa = pygame.surfarray.pixels_alpha(surf)
+ pa[:] = alpha.T
+ del pa
+ return surf
diff --git a/uilib/paint.py b/uilib/paint.py
index 92e5da75f..865b358a2 100644
--- a/uilib/paint.py
+++ b/uilib/paint.py
@@ -68,19 +68,23 @@ def _ipt(p: Sequence[int]) -> Point:
@lru_cache(maxsize=512)
-def _text_surface(text: str, font: "pygame._freetype.Font", color: Tuple[int, int, int, int]) -> pygame.Surface:
- """Cached RGBA surface of `text`, pen origin at (_TEXT_PAD, _TEXT_PAD + ascender)."""
+def _text_surface(
+ text: str, font: "pygame._freetype.Font", color: Tuple[int, int, int, int], size: int = 0
+) -> pygame.Surface:
+ """Cached RGBA surface of a text run, with optional font size override."""
from uilib.misc import get_text_size # local: uilib.misc imports uilib.paint
- asc = int(font.get_sized_ascender())
- desc = abs(int(font.get_sized_descender()))
- tw, _ = get_text_size(text, font)
+ asc = int(font.get_sized_ascender(size))
+ desc = abs(int(font.get_sized_descender(size)))
+ tw, _ = get_text_size(text, font, size)
surf = pygame.Surface((tw + 2 * _TEXT_PAD, asc + desc + 2 * _TEXT_PAD), pygame.SRCALPHA)
surf.fill((0, 0, 0, 0))
prev_origin = font.origin
font.origin = True
try:
- font.render_to(surf, (_TEXT_PAD, _TEXT_PAD + asc), text, fgcolor=pygame.Color(*color))
+ font.render_to(
+ surf, (_TEXT_PAD, _TEXT_PAD + asc), text, fgcolor=pygame.Color(*color), size=size # pyright: ignore[reportCallIssue]
+ )
finally:
font.origin = prev_origin
return surf
@@ -229,39 +233,28 @@ def draw_text(
fill: Optional[ColorLike] = None,
font: Optional["pygame._freetype.Font"] = None, # pyright: ignore[reportAttributeAccessIssue]
anchor: Optional[str] = None,
+ size: int = 0,
) -> None:
"""Draw text using a pygame._freetype Font.
Default anchor matches PIL's `la` (left, ascender): `pos` is the
- top-left of the line box (ascender line), not of the visible glyph
- bbox. This keeps text vertical alignment consistent regardless of
- which characters appear (with/without ascenders or descenders).
- Also supports anchor='mm' (middle/middle of the glyph bbox).
+ top-left of the line box, not of the visible glyph. `size` overrides
+ the font's registered size without mutating the shared font.
"""
if not text or font is None or fill is None:
return
color = _color(fill)
x, y = self._abs_xy(pos)
- asc = int(font.get_sized_ascender())
+ asc = int(font.get_sized_ascender(size))
if anchor == "mm":
- # PIL anchor='mm' centers on (PIL.getbbox(text).w / 2, (asc+desc)/2).
- # uilib.misc.get_text_size matches PIL getbbox semantics. Use int()
- # (floor for positive operands) — not round() — because PIL's BASIC
- # layout effectively floors the fractional pen position; Python's
- # banker's rounding on .5 boundaries (e.g. 51.5 → 52) would push
- # the glyph one pixel right of PIL.
from uilib.misc import get_text_size
- desc = abs(int(font.get_sized_descender()))
- tw, _ = get_text_size(text, font)
+ desc = abs(int(font.get_sized_descender(size)))
+ tw, _ = get_text_size(text, font, size)
base_dst = (int(x - tw / 2), int(y - (asc + desc) / 2))
else:
base_dst = (int(x), int(y))
- # Blit a cached glyph run rather than rasterizing per draw: text is
- # re-drawn on every widget refresh, so freetype rasterization otherwise
- # dominates the hot paths (meters, readouts, axis labels). The blit
- # honors surface.set_clip, which font.render_to does not.
- surf = _text_surface(text, font, (color.r, color.g, color.b, color.a))
+ surf = _text_surface(text, font, (color.r, color.g, color.b, color.a), size)
self.surface.blit(surf, (base_dst[0] - _TEXT_PAD, base_dst[1] - _TEXT_PAD))
def draw_arc_aa(self, cx: int, cy: int, r: int, clip: Box, color: ColorLike) -> None:
diff --git a/uilib/text.py b/uilib/text.py
index 8e8d03178..6232d453e 100644
--- a/uilib/text.py
+++ b/uilib/text.py
@@ -38,7 +38,7 @@
from common.color import ColorRGB, RectBorder, tile_color_for
from uilib.paint import ColorLike
-from uilib.glyphs import RoundedRectGlyph
+from uilib.glyphs import LoopIconGlyph, RoundedRectGlyph
from uilib.glyphs.badge import BadgeGlyph
from uilib.radius import Radius
@@ -547,6 +547,33 @@ def _draw_outline(self, ctx):
return
+class LoopPluginTile(PluginTile):
+ """Plugin tile for loopjefe tracks: renders the racetrack glyph + track number
+ centred in the tile instead of the 'Loop N' text label."""
+
+ def __init__(self, *, loop_num: int, **kwargs) -> None:
+ kwargs.setdefault("text", "")
+ super().__init__(**kwargs)
+ self._loop_num = loop_num
+ self._loop_glyph = LoopIconGlyph()
+
+ @override
+ def _draw(self, ctx) -> None:
+ from uilib.glyphs.tint import tint_mask
+ from uilib.misc import get_text_size
+ num_str = str(self._loop_num)
+ nw, nh = get_text_size(num_str, self.font)
+ g = self._loop_glyph
+ gap = 4
+ total_w = g.width + gap + nw
+ gx = (ctx.width - total_w) // 2
+ gy = (ctx.height - g.height) // 2
+ ny = (ctx.height - nh) // 2
+ ox, oy = ctx._f().topleft
+ ctx.surface.blit(tint_mask(g.render(), self.fgnd_color), (gx + ox, gy + oy))
+ ctx.draw_text((gx + g.width + gap, ny), num_str, fill=self.fgnd_color, font=self.font)
+
+
class ScrollingText(TextWidget):
"""TextWidget with horizontal ping-pong scrolling for overflow text."""