From df178edb2fcd6585f22b7b49486790af8584e0f6 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 29 Aug 2026 15:22:48 -0400 Subject: [PATCH] loopjefe plan --- common/contexts.py | 8 +- common/loop_progress.py | 44 +++ common/parameter.py | 40 +- modalapi/led_render.py | 113 ++++++ modalapi/modhandler.py | 341 ++++++++++++++++-- modalapi/plugin.py | 22 ++ modalapi/plugin_customization.py | 37 ++ modalapi/websocket_bridge.py | 52 ++- modalapi/ws_protocol.py | 56 +++ pistomp/beatsync.py | 161 +++++++++ pistomp/footswitch.py | 19 +- pistomp/handler.py | 7 + pistomp/lcd320x240.py | 84 ++++- plugins/__init__.py | 1 + plugins/loopjefe/__init__.py | 87 +++++ tests/conftest.py | 4 + tests/integration/test_tap_tempo.py | 32 ++ tests/pedalboard_fixtures.py | 5 + .../recording-chase.png | Bin 0 -> 9846 bytes .../play-bar3-beat3.png | Bin 0 -> 10875 bytes .../transport-beat-0-on.png | Bin 0 -> 9197 bytes .../transport-beat-1-on.png | Bin 0 -> 9203 bytes .../transport-beat-2-on.png | Bin 0 -> 9202 bytes .../transport-beat-3-off-50000us.png | Bin 0 -> 9165 bytes .../transport-beat-3-on.png | Bin 0 -> 9173 bytes .../overdub-from-output-set.png | Bin 0 -> 9938 bytes .../empty.png | Bin 0 -> 9537 bytes .../recording-and-playback.png | Bin 0 -> 10255 bytes tests/test_beatsync.py | 287 +++++++++++++++ tests/test_loopjefe_behavior.py | 92 +++++ tests/test_websocket_bridge.py | 127 +++++++ tests/test_ws_protocol.py | 103 ++++++ tests/v3/test_footswitch_led_driver.py | 188 ++++++++++ tests/v3/test_footswitch_presets.py | 30 +- tests/v3/test_footswitch_state_view.py | 315 ++++++++++++++++ tests/v3/test_hardware_config.py | 38 ++ tests/v3/test_midi_learn.py | 277 ++++++++++++++ tests/v3/test_reactive_parameter.py | 27 ++ tests/v3/test_taptempo_led.py | 241 +++++++++++++ uilib/__init__.py | 3 +- uilib/footswitch.py | 174 ++++++++- uilib/glyphs/__init__.py | 2 + uilib/glyphs/loop_icon.py | 122 +++++++ uilib/glyphs/perimeter_progress.py | 140 +++++++ uilib/paint.py | 41 +-- uilib/text.py | 29 +- 46 files changed, 3228 insertions(+), 121 deletions(-) create mode 100644 common/loop_progress.py create mode 100644 modalapi/led_render.py create mode 100644 pistomp/beatsync.py create mode 100644 plugins/loopjefe/__init__.py create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png create mode 100644 tests/test_beatsync.py create mode 100644 tests/test_loopjefe_behavior.py create mode 100644 tests/v3/test_footswitch_led_driver.py create mode 100644 tests/v3/test_footswitch_state_view.py create mode 100644 tests/v3/test_taptempo_led.py create mode 100644 uilib/glyphs/loop_icon.py create mode 100644 uilib/glyphs/perimeter_progress.py diff --git a/common/contexts.py b/common/contexts.py index f21bf9923..44823603b 100644 --- a/common/contexts.py +++ b/common/contexts.py @@ -83,7 +83,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 @@ -189,6 +189,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..7bb04ebbb --- /dev/null +++ b/common/loop_progress.py @@ -0,0 +1,44 @@ +# 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` + FREE = auto() # the loop runs, but no transport gives its 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..b44b3b462 --- /dev/null +++ b/modalapi/led_render.py @@ -0,0 +1,113 @@ +# 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 | None, + 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. None means that the grid runs free. A + free grid gives a beat but no bar. There is then no position, and the loop + shows a full ring. + `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. + is_chasing = state in spec.chase_states or (bars > 0 and measure >= bars) + + if bar_phase is None: + if bars <= 0 and not is_chasing: + return None + if state in spec.steady_states: + return LoopProgress(LoopFill.STATIC, color, bars) + return LoopProgress(LoopFill.FREE, color, 0 if is_chasing else bars, 0.0, pulse) + + if is_chasing: + 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 36722ee91..e5bd60aad 100644 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -58,6 +58,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 @@ -87,9 +90,11 @@ parse_message, LoadingEndMessage, LoadingStartMessage, + OutputSetMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, + BeatSyncMessage, AddPluginMessage, PatchSetMessage, RemovePluginMessage, @@ -114,6 +119,7 @@ ) from pistomp.footswitch import Footswitch from pistomp.footswitch_chords import FootswitchChords +from pistomp.beatsync import BeatGrid, TickState from pistomp.input.event import ( AnalogEvent, ControllerEvent, @@ -137,6 +143,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 @@ -254,6 +268,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] = {} @@ -284,9 +301,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) @@ -450,9 +465,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 @@ -467,9 +480,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 @@ -479,9 +490,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 @@ -522,21 +531,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 @@ -545,9 +563,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() @@ -560,6 +593,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: @@ -592,11 +648,140 @@ 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: + tt = self.hardware.taptempo + beat = self.beat_grid.tick( + _now_us(), + free_bpm=tt.get_bpm() if tt else 0.0, + free_anchor_us=int(tt.anchor * 1_000_000) if tt else 0, + ) + # 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) + + @staticmethod + def _taptempo_phase(fs: Footswitch, beat: TickState) -> tuple[bool, bool] | None: + """(lit, is_bar_start) for the taptempo flash. None when there is no + beat to flash on.""" + if fs.taptempo is None or not fs.taptempo.is_enabled() or not beat.is_running: + return None + return beat.is_flashing, beat.is_bar_start + + def _render_taptempo(self, fs: Footswitch, beat: TickState) -> tuple[tuple[int, int, int] | None, LedDisplayStyle]: + """Renderer for the taptempo footswitch. With no tempo, the default + per-footswitch renderer applies.""" + phase = self._taptempo_phase(fs, beat) + if phase is None: + return self._render_footswitch(fs, beat) + lit, is_bar_start = phase + if not lit: + return None, LedDisplayStyle.SOLID + return (_METRONOME_DOWNBEAT_RGB if is_bar_start else _METRONOME_BEAT_RGB), LedDisplayStyle.SOLID + + def footswitch_tap_flash(self, fs: Footswitch) -> bool: + """True when the LCD tap border is lit. Steady with no tempo.""" + if self._last_beat is None: + return True + phase = self._taptempo_phase(fs, self._last_beat) + return phase is None or phase[0] + + 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: + return loop_progress(spec, plugin.output_values, None) + return loop_progress( + spec, + plugin.output_values, + beat.bar_phase if beat.is_anchored else None, + metronome_brightness(beat.is_flashing) if beat.is_running else 1.0, + ) + + 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_running: + 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: @@ -877,17 +1062,27 @@ def _handle_ws_message(self, msg: WebSocketMessage): tp = self.current.pedalboard.transport_plugin tp.set_param_value(ROLLING_SYMBOL, 1.0 if msg.rolling else 0.0) tp.set_param_value(BPB_SYMBOL, msg.beats_per_bar) - tp.set_param_value(BPM_SYMBOL, msg.bpm) - if self.hardware and self.hardware.taptempo: - self.hardware.taptempo.set_bpm(msg.bpm) - if self.hardware.taptempo.is_enabled(): - fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) - self.update_lcd_fs(footswitch=fs) + self._adopt_bpm(msg.bpm) if self._lcd is not None: if sync_changed: 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 @@ -1217,6 +1412,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 @@ -1236,6 +1448,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 @@ -1244,8 +1460,33 @@ def _publish_bpm(self, param: Parameter) -> bool: """Publish the BPM to the transport.""" return self.set_mod_tap_tempo(param.value) + def _adopt_bpm(self, bpm: float) -> None: + """Take a BPM as the local truth: the :bpm param that the tweak readout + subscribes to, and the tap tempo that the TAP slot and the free beat + grid read. mod-ui does not send its transport message back to the socket + that sent the change, thus a BPM that leaves here gets no echo. The tap + and the tweak knob would else keep the old rate. + + set_param_value routes through reconcile, not commit, so the :bpm sink + does not fire back at the sender.""" + # mod-host emits no beat_sync for a `transport` bpm change, thus the + # grid must take the rate from here or it keeps the old one until the + # next bar heartbeat. + self.beat_grid.set_tempo(bpm, _now_us()) + if self._current is not None: + tp = self.current.pedalboard.transport_plugin + param = tp.parameters.get(BPM_SYMBOL) + if param is not None and param.value != bpm: + tp.set_param_value(BPM_SYMBOL, bpm) + if not self.hardware or not self.hardware.taptempo: + return + self.hardware.taptempo.set_bpm(bpm) + if self.hardware.taptempo.is_enabled(): + fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) + self.update_lcd_fs(footswitch=fs) + 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 @@ -1254,6 +1495,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 @@ -1425,6 +1676,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) @@ -1521,6 +1783,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: @@ -1870,9 +2133,13 @@ def set_mod_tap_tempo(self, bpm: float | None) -> bool: if bpm is None: return False if self.ws_bridge is not None and self.ws_bridge.send_bpm(bpm): + self._adopt_bpm(bpm) return True resp = self._rest_post(self.root_uri + "set_bpm", json={"value": bpm}) - return resp is not None and resp.ok + if resp is None or not resp.ok: + return False + self._adopt_bpm(bpm) + return True def set_sync_mode(self, mode: SyncMode) -> None: """Optimistically switch the clock source; mod-ui's transport echo 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..ae0b4c34c 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -121,6 +121,34 @@ class TransportMessage: sync_mode: SyncModeWire = "none" +# mirrors BEAT_SYNC_FLAG_* in mod-host src/effects.c +BEAT_SYNC_NEW_BAR = 0x1 +BEAT_SYNC_TEMPO_CHANGED = 0x2 + + +@dataclass +class BeatSyncMessage: + """A sample of the transport clock at `t_us`. Consumers calculates the + position after `t_us` from `bpm`. Each sample replaces the sample before it.""" + + t_us: int + bpm: float + bpb: float + beat_in_bar: float + flags: int + + @property + def is_new_bar(self) -> bool: + """The sample occurs on a bar line: its phase is correct.""" + return bool(self.flags & BEAT_SYNC_NEW_BAR) + + @property + def is_tempo_change(self) -> bool: + """A discrete bpm or bpb change. The sample gives the new tempo, but + its phase is not correct.""" + return bool(self.flags & BEAT_SYNC_TEMPO_CHANGED) + + @dataclass class AddPluginMessage: """Plugin present in a (re)connect/load dump, or dynamically added (add ...).""" @@ -176,6 +204,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 +253,14 @@ class UnknownMessage: TrueBypassMessage, PluginBypassMessage, TransportMessage, + BeatSyncMessage, AddPluginMessage, PatchSetMessage, RemovePluginMessage, ConnectMessage, DisconnectMessage, ParamSetMessage, + OutputSetMessage, MidiMapMessage, UnknownMessage, ] @@ -338,6 +377,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 +418,17 @@ def parse_message(raw_message: str) -> WebSocketMessage: sync_mode=cast(SyncModeWire, sync_mode), ) + # Format: beat_sync {t_us} {bpm} {bpb} {beat_in_bar} {flags} + case ["beat_sync", t_us, rest]: + bpm, bpb, beat_in_bar, flags = rest.split(" ") + return BeatSyncMessage( + t_us=int(t_us), + bpm=float(bpm), + bpb=float(bpb), + beat_in_bar=float(beat_in_bar), + flags=int(flags), + ) + 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..1d8eec39e --- /dev/null +++ b/pistomp/beatsync.py @@ -0,0 +1,161 @@ +# 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 + + +@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 + is_free: bool = False # the beat comes from the tap, not from the transport + + @property + def is_running(self) -> bool: + """True when there is a beat to flash on. Only an anchored grid gives a + bar. A free grid has no `bar_phase` and no bar start.""" + return self.is_anchored or self.is_free + + +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 + if not msg.is_new_bar: + self._retune(msg) + 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 + # The sample occurs on the bar line, thus its own beat is the + # crossing. Seed one beat behind, or the first tick() does not find + # the crossing: the beat index is already equal to the modulo target. + self._last_beat_idx = int(self._anchor_pos // 1) - 1 + + def _retune(self, msg: BeatSyncMessage) -> None: + """Take the tempo from a sample that has an incorrect phase (a tempo + change or a meter change). The next bar heartbeat corrects the phase.""" + self.set_tempo(msg.bpm, msg.t_us) + self._bpb = msg.bpb + + def set_tempo(self, bpm: float, now_us: int) -> None: + """Change the rate without a clock sample. mod-ui's `transport` path + (a tweak knob, a tap, a browser) writes the mod-host globals but emits + no beat_sync, thus this is the only way the grid learns that rate.""" + if bpm <= 0: + return + if self._anchor_t_us is not None: + elapsed_us = now_us - self._anchor_t_us + self._anchor_pos += elapsed_us * self._bpm / 60_000_000.0 + self._anchor_t_us = now_us + self._bpm = bpm + + 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, free_bpm: float = 0.0, free_anchor_us: int = 0) -> TickState: + if self._anchor_t_us is None: + return self._free_tick(now_us, free_bpm, free_anchor_us) + + if self._bpm <= 0 or self._bpb <= 0: + self.clear() + return self._free_tick(now_us, free_bpm, free_anchor_us) + + if now_us - self._anchor_t_us > STALE_AFTER_US: + self.clear() + return self._free_tick(now_us, free_bpm, free_anchor_us) + + 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, + ) + + def _free_tick(self, now_us: int, bpm: float, anchor_us: int) -> TickState: + """Beats without a transport: the tap phase at the tap tempo. No bar, + thus this grid has beats only and no renderer can show an accent.""" + if bpm <= 0: + return TickState(False, False, False, self._bpm, self._bpb) + period_us = 60_000_000.0 / bpm + phase_us = (now_us - anchor_us) % period_us + return TickState( + is_anchored=False, + is_flashing=phase_us < FLASH_US, + is_bar_start=False, + bpm=bpm, + bpb=self._bpb, + beat_phase=phase_us / period_us, + is_free=True, + ) diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index 08dcd6be4..98599baa1 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -168,24 +168,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 78482d69f..08420ee19 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -118,6 +118,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() diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 569bbc393..a04ce9e11 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -18,16 +18,18 @@ import functools import logging import os +import re import time import socket from collections.abc import Callable, Iterator -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Any, Optional from common.fonts import font_path import common.token as Token from pistomp.controller import ControlType 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 @@ -56,6 +58,7 @@ Parameterdialog, ScrollingText, TextWidget, + LoopPluginTile, ) from uilib.glyphs.badge import BadgeGlyph from uilib.menu import row_label @@ -615,13 +618,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[str, Any] = dict( plugin=plugin, box=box, - text=label, outline_radius=5, parent=parent, action=self.plugin_event, @@ -630,6 +630,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 @@ -862,6 +870,37 @@ 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 _tap_flash_fn(self, footswitch): + if footswitch.taptempo is None or self.handler is None: + return None + return functools.partial(self.handler.footswitch_tap_flash, 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. @@ -874,6 +913,8 @@ def draw_footswitches(self): slot_w = pitch for fs in sorted(self.footswitches, key=lambda f: f.id): x = pitch * fs.id + state = None + loop_icon = False if fs.preset_callback_arg is not None: label = self.footswitch_label(fs, slot_w) fs.set_display_label(label) @@ -885,9 +926,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 "" @@ -900,10 +947,15 @@ 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), + tap_flash_fn=self._tap_flash_fn(fs), + 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() @@ -917,16 +969,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/integration/test_tap_tempo.py b/tests/integration/test_tap_tempo.py index e9c99d4b7..a332f8a7a 100644 --- a/tests/integration/test_tap_tempo.py +++ b/tests/integration/test_tap_tempo.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock +from modalapi.pedalboard import BPM_SYMBOL from tests.types import SystemFixture @@ -43,6 +44,37 @@ def test_set_mod_tap_tempo_falls_back_to_post_under_backpressure(modhandler_syst assert call_args.kwargs.get("json", {}).get("value") == 120 +def test_set_mod_tap_tempo_adopts_the_value_locally(modhandler_system: SystemFixture): + """mod-ui does not echo a transport message to the socket that sent the + change. A tap therefore has to set the local readers itself: the :bpm param + that the tweak knob subscribes to, and the tap tempo.""" + handler = modhandler_system.handler + assert handler.current + tp = handler.current.pedalboard.transport_plugin + tp.set_param_value(BPM_SYMBOL, 120.0) + + assert handler.set_mod_tap_tempo(91.0) is True + + assert tp.parameters[BPM_SYMBOL].value == 91.0 + assert handler.hardware.taptempo is not None + assert handler.hardware.taptempo.get_bpm() == 91.0 + + +def test_failed_send_does_not_adopt_the_value(modhandler_system: SystemFixture): + """The value never left, so the local readers must keep the old rate.""" + handler = modhandler_system.handler + assert handler.current + tp = handler.current.pedalboard.transport_plugin + tp.set_param_value(BPM_SYMBOL, 120.0) + modhandler_system.ws_bridge.send_bpm = MagicMock(return_value=False) + failed = MagicMock() + failed.ok = False + modhandler_system.mock_post.side_effect = lambda *a, **k: failed + + assert handler.set_mod_tap_tempo(91.0) is False + assert tp.parameters[BPM_SYMBOL].value == 120.0 + + def test_set_mod_tap_tempo_none(modhandler_system: SystemFixture): """set_mod_tap_tempo(None) is a no-op.""" handler = modhandler_system.handler 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 0000000000000000000000000000000000000000..a3fbd43ed74b5e0daa323f8f6483c20435b8f7d7 GIT binary patch literal 9846 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..58dc7677d240771049ad0e3eec4d379c8ce3fcd6 GIT binary patch literal 9173 zcmd^l_cz;r{BM-nt9F%8yK0x(RBF{IwQ6sru`2cmYJSwNqN-@jMvS7ip!Td$d$vXG z9kscye$Tz<+;jhhn;#q}iRAr$J)h6VdWz9~szyP|ObUTOD4wV*>p>uRkHGJH#5cgt zD;<702!tK-L|Mte@8kA7i7~C?mDHXu4=;5R7t4(sH`4SU#ZK@;iB6VFMHwd_V|6IQ}S(T*V z;u5_}-JH|i&70xB>Ua1(-Sh?RcZscU3cX{98%pwPiV1p~!%U99({ zv_W@?3UQk;Hwzov^?FgxpFe-TXG#v8=xKQ#(?d4fL+36~T^M2`srAqX zq>;JWYW%4*I*6{9f#yP!Z=B7V{PsUeds4%$Pr4PGFAg-9V(x~%p0{TDv%bE*ySux* zoG%&s&H7dSoKiU9-H2s`KN5*m=Ng4qr@1=Sl=WSz6$@`1cE#UOQx%3eU0+?w7e+$} z|9)HAZ)PV` zOGrv$#^INFDZRM8Jti@G;`DxR1maCh47#|OCPvvu_<@ej(D3kZSC?u4jw9lohJ&-S zvy&4atf!KRCw+S&=L*Lgk+7*90U`AHxxF2~RA+?2`tDjjejJX{G_N)lq={j^a|ek; z;^X6cu&~MbeVNC9Klma#B0^k3V!qz9Xmd{1cju=Rl!j(>VxrK1f6$^H)gR%1_y>Eo zfuU~wq{AmDNI^;pcXD!acjql4j)c8_B;qv8UVKZFKlP0_aoA@JCPkwF+?Vg}!Zuk5 zk{V`cXc$FfU}#7}5cVycXmV`qed9r2Ute9F)WPBD;p%!-@aanWJGx7pEjJxW?u*4MS;;NW0mYpcm~HNk-2b#k4ofSZmq^TUTV-V3peI7`psad?IC z!0O>3#m^X8-mb1L<`0=EDOHGqD`jxRIyYXkb8y`FA#av%gqfY0u@}1y>*?)%Is8e< zUf;;e%gf)tUYVofY{JoOOlEKe0nX%6j-G`@Y5JdE?a?sQaLG6i7gvc@OZGQwFWF;E zd2@r$w&~42^#2|jbuQ+3)$)qT z=Hp-ov5eT**nM$1Xt{F&d4`;Sr6n%-yBee>@^(FS&lM>vZ9Xh3@rJXD_Q&TB7!pwvIj2R)rBR@+^N}^8q{>7$U z*NS^|zMGVUMD$(g-09g_c23<>b@fk^SkuzKd5H>r{rwM+j-Ln#TjlsuH~wzVLZMI_ z8=HV5k(6F?Dk?_O$XfTsoilTd=l$uzR`m#nfB>1>*x4FaS!OOx4{R!y!G|< ze|AU+MAjagnwlOQ9zF|UXNWD(LzkBe=M3>Kr3LXAaM5H0k2E9C|Bhpx+cDKR^fUY@ z4_tTJ{fiF?Aj9?G0f6~`9yr#UH*a>>U0)ryY-42;3awi&np*rbY!;i51~nvMepjcf z+qHJBSGWYCH|9d_E-qUuS-6?z-K&?nfMJLH6RgC8UW1qD@oqS*f%8ymYiIyAl6 zGypjh2Yv3|a_KXg|3toUdEd|J=j-Fw+jbrQ-I}r+l0RnIpwiRR>+ztmwX-|2lmWdd zf)z8bb;~lW>9MV7zB+Rb!1?(2xJ_4bKYTdR+1UvYgrA?ElQXZWiG{)d4AdbX-J9H8 z4)_H$)3iBA`m#LJSm3FK2ETwne&M_IAaJ+f&PXF~P`v6Yf8A*LTq{fFDAT?9R*bSn zfgTsl%JOm?lW16TPla7~oK@|+ckhOOG&VGp8CU$qt^cU5uFlHJqGD5M0u^|TPxO0f zDSqoO8vtI<)$gFR8Crig%UgC9u(DE8ep6p;a1?@8&2xU1Vd3G&+q2KXkQrgnk`F8_ zEcEsCYTV`n)<+9wW=u1*3iZ&S(xHg ztA%ZocFN!dt@Nb|w_H?i;_;{SzJ2>vfb3_wWkAeUREt?OB`e3d(5H5fAOQUqwEZgw zzKh6790V!>H}LfIoa9hMEdRHEIn>{uXBA*_Kh$yX!}!b$+=-ilfDvUJoI@YonarS!j*!UNq3Nsqj6klbGTjnS3c28m6EF}yS>IO>$GeN}pFQE6F9tt& zA1pt4{m7c{+r{Z&B2-@EFXXBJ_2n_6kolB^XmtB@r9*5?rjNJxU~ezVx4pEMp7U)= z3P9QTl8x#~OW6P%pLFGmT0$!5#k}t#H!tt8>AKg-%1We*R{5{Kx$n6pf%)>M$-`uk zwyG3b)Dgx4!RrO-AG5OT?Cb!@tXJ_o{pd8|O0T1%gP?ZQ)6~?2!;5wKa7Rb;UtiK9 zr*D!m=xUyxp59BE`Mx@3X~pWLQq6BOHIerIy*e^Yo$LBy zRdL-@Cr3dWNl8X_N5+fq)PJtd17}Y}1z%^;;oG)z^^Lp8s}*L&9UI#YXfbyW??Kumy>@TI?X%VT#z9+Bh(x7E~)we-Jp`|_EN zib~`{B^P+#)1l65?nzEu|MQ?ZuaYXJ6jnx87hCWvEL zNFMUjkuf^Ie2EH?xO$B1DIpAZBSLNls5+j>P&nW$<2nqiPQXBoDZhU7b7Mn8 zhk=gH{x>Pbt4OSGAtJq7Zi-YI{Mui z+w*jy%))t7yJzK~<#O8{chAJcWIiDz-p1I}^uxVU^EB3%3~VHnG_AOnyk+E5NlD4G zclbn3^_woWMrD^>VB(PzE5)$kjp){;Al>@gyF{yhkph^ z1HVEbfE+s8Yo}^d*svBlWB;zBcq#6WZ@u^P+kK-FkFJD@ggo#o1YS~1zEcV$8WU)NDG|H)kBY+lR5mGBHjvh0Oili%tdSe zBO||NW?=buLN$DJvSY1OP`Rlp$HaYcE9BAmK*ockjWVd~T-~!>Rk9~2-LKO@g>^VV3ZYfWtvatB}hh=gBfaY`5POl($^T@bNhT zQYw0yRa{oKv$bV_Hi(apFDNJg3T(E?-%~^5%|bjPUbX7gueNln;8Sp)rKP37loS{9 z-@kufOzdrN^^aufGh{F*ptlpPm3t}2ggeV;MRsw zkN=^?>dFe}xJcOD^#09_4bTafXPf0frQ^_R)e_+AY}yG!LauQ*T%KB@k&%%G7frnC zkLhXuix zQx%V+$ZaZ&%gV~^y5f5K`f}CWJv>A&enN240fKn*0VjLE-iz98VT&M;L;Sk0Uvblp zIgwVBQpLfFt-e}X13(6(ynTCdd5H`PG8O>D5a{iF1h^HkF@~O-1D3;e7fJAcX|PZ{ z%wjD}@h)NbEhZ*DI#Owt1Q-ekKVW_)0;U_>nL|zfAnE~9R8&-e76GRj85s$bAfkSb z|9+07`vTw{pcazkadR0CUS3}{3M!4<&gm2tTRfJ!06p0-6^=NWo0|h%h@xZ#^o~2L z^kBjl>5qwt0h$!dR;!0OPYRH^PqSpaXTH7!?KZrb+am_PZ)9Wy^c;O9ksZ<&SdT;& z>CqoQz_Fg59c}hhoSdGrabvf^#jse;%Gbd-e1?RIm-?g|w*XKK6Lw zJ|Evyqp!P($!%#-F)=ZD`AhKRkHF3rMKPwC^;<%PXH9UYx+xlM{{JhOyt z$A4jAVR3PBVD$S|4ti5~TduFpffj7JI=i2`1!O-BF@gQ05HK!iG#cWe*iAS)x&Gl@(WG)w#VvB_y9x8-6*m{T>*$;D-^85#KO z*~nCd9XQF$iwg}+&03e~(XlaQK`0B$$FwwtJ9qkDRJkuU|FfxQ!@7i4yg?i2qOJti z{uM-jU!U7t9m@B0gpIM67Y^Xn_iTAHLqmF6+Q&zpyX))H;N^*liKeEeSVmznrZ`{# zWu&DuDz`oiP8wT*f+Wds`a^2M4e>;CQ83 zo?2V8+t*vABfv}puw1|fuG3ltZdXT=F+!zYS??}jK{=MIHxm`Frbb4T*tTHo)E%9j z$(NJeot;SBh#K&%<;`bs;C`apPcIIK0HO;{ag(>Ffn&+ay6n4OPRGdTzR)BsB;?i> zK>}p|->{oxa`N&(P-ye-10&Xa@|&#QJUcsE0nFk*RA39asIc7qc!n2eGxbA9k(Hjl zz<|Hps>Ra8WZIioisdPQb7KLX>pF#?lcbHA`y3oF{!|7=#`|<{qN&&x>O4T76lDkW zy>rOR&9xVs2BfIfoa4VaAsC}SAVLII zrlzJod|=)f$be#}r>7kp96;&qdCfEm4uIx*^5luZahW~UsJ$dJXd3`LK<5=HbD%Mp z*)KNunSeK1%CbT7t%Jc3)J0CLT|ixo=NZHf@os*8o|}f`NCM2t8%x9+WeybobyJfZh*^?3)!teaX=NN74k^ZV+JXoN z3Ju<#k$}s)y1D{xOvBogjX05#l72o?FBk=2`plwnz9odM+XFFok0Dk9vD^cwWMn9W zjEoEHQ5Lm?;WK>j2wGn9}a(U{B?; zmB$$yqQ9X{G;+Z*rt|mx=Vim?9a1cmSp7TyQy$RwKYvM=h1G>3z94ItsI@T9;SREYQR5%&!-{ws+sP-`xYquf^k; zmXN%-5rW4;Pp@)S2oP7kru5EPeQ);Ml*>aTM|3-iv1Z=^_;y@Z@t0c_##^ha-K&S_ zoJ1%#NMUh&JoQTUQQEoPJw^rwkd`O|@^Ku&t;NU1krNZ2{K<6xj8yR5nTxyqz-Mbp z6!@YHEuWP>`bvx-aDbA=`HhW%wMZ{-AD_|L8=qq(I9uXSi^X|)kMl<^W>L$b4x3B?b5dD*u6<80+zo9MBuc6acn@!zU*U?_P?G zh#+TEV3`h8&AZ3Xe*m&S#bCLdp`yaW(~JG3?m$03I(qtylUFqM^+Rcu=0ew7RYR(A zOvxD;w{PEmqMaBQH!v{3B5K>=n1fUefvsO%o`ZP>fhQO{+`c{)ixf2--B%!nfu7#n zYz)2(h*y2ghK@sAUHB7*SNQGQLK7JQ9%pB}{T>=Eop1C#KR++GXiRH*NkdI7V%J5E z^&l5ynHxwdEGz_+5fvS+lOd*p8GqCH8>BwIK0Y5RUChj8J7XEa@O3A$4cUu~S2C3#@N8Ni#L>Xsxl|*(y}=v;t}av@g_!L07hHtzNP}L913G(rY)qYt=Jg|L zYU<&cnFf#nmg#tW1exTZ#1UYXW#ms_BY$M>^J!~q-;s3F z($UFz=xVB>(%B8b6F`kg(;?_QlS=z1aQFrwZjdAj(8QF1TT4lKtaG@g#dG&As+-gZ zr90)pPBnWwl!}n5Rlw8rR6zWnY9Y*4D;ma`?Im=mH}cv0v%fnaCfXj%382d_kE0@o zd?6`0f^=dF%L$n>e(~Z( zP>>u#YJYz}BO~MQ0vW_bKv;O_Td@^Q=HU1K{;Fe3h_#ti=HSF8v{RHXRU`VMo#4@R zAWi@5Y~aRbd$Ugts*5PaPsVcY{I@-X(VrlmKXbu=_c$;f7_of%_0a~l?Z zgl~QxhjVU9BhS3d%|G6hg|voJTC;TbKF0#5l=4#bn3bFm3};EV{1WKK&@B8=fgM$uL(_QB$H>@9tGNjn|Xai>gg!Pcz zVVtFJU?F>BuR`!eU1sJih>@sackkW>N-tk#Ngr(h;yPfHhGxavH& z*GU7jN1z0pnjyyM^!%KR;D&DsNhHkI_n6TtD4CrY;dHO^VVB28jrP?;AZhjV^*e1v zm8gNd2*xpXuEm@IXQAcZf#`YK1QC%41%gPEx+BF4lMa**@a)ibL|5Ex)6$xzrjOsO znRZ2)ZZJVoitj-ad6U)c#WH|-dQ9|SVA%ZNseptvT}(El+k-Ito4wfXVoUjm6VPNy z&!}4=?*k~b79Mf9@2y#!jkf3sjxW=TnMA&2d#Ayjuw-w`2`(OEh#6mIpeVqDygy?n zN?6iEfBpRVzy1{PO{|#!8IAQLFCr&iTlF>acd)B)Ky{`Kyo-XIqB==l_bF*Wdq1w z@e)U?L@nI3*9HL1gT<;P__Ej#qt!B`d?o!8QNKJqv>0-I$(s`d%ZkT`WUn#54}!z@ ze|LgLFt4;Hkxr5Z9wGIDothY^dvWIYoE)}Fbq9wPkkW$$N@D@9W<<9FBE_`@47F+$ zWdVE)7<#?hFcET0X=!S&{qo^kU`zff-N_OYPmr}C-rT!WSeE1rhxho1aaJD}YG(o) zKkFR;3=FVg6s~Q46OdOBdAS8GYJ!wr1I#!OIS+8}#zCfpg^w4frKN$y#;lH3i)Z@j zi|OAGa&yXyy0`d_6roo*1rf+6v%0*$=D$A6$3q@Pr>CX@^Kd5lgQab4BoFA?Ei5Bs ziplFf*`w>uy1?VImX_oKlh|_d3X+-781nEfLRa-#1>W-GQ~h%`frK% z|HI=8@7A@#Z{kjlWruTD1`e)Kq;d3cm*w;vLBxz}%U#G;)a&6N#MH$!Fk<%ot)fH#bSb9#IkGz zm2J$=kNBXXh~XlJp>%O|55aeVcJJ=!ARr+4T2liuo8J7aJ*Cgh$2XYTzx?+fF=4m| zXwrlP$}<5#=yY`XBTm5Iuogn2E@m)6Y*q&!jE+Gxpm4rbg@=bHz|cP@Xfu_!mZ2DO z1*{X;v537)f{~63g z+Y2z<7Z7kc!>t2f1)!)N=$}c9L#a`t>+Hj^T90}V2mtLJpP#Hz07~D#BGZ~GvOJlv z;=EyzM?%ZP9?=ZUG0-_?HQcEh-oQ?RPuI11LLH&SlSk8IUkMr@5+(|~9pIbfqUq0U z9J3z{`R&rVm(7d48?3@FW-5XL7eb9NE3c%kuC72B1TM?|Fg1Bt%xmKu>_|X{W6Pvk zZg3=ID@uO<-o2O3&WC$@uY7&MHt;i>9)@i`_Cauf&UbG8zrIOUvT5Z{;(M8*l07uX zKjP{MC^DihKKY_BBTk=RbwW2A1pm>xU|IMXFW6IqNPccEF5kn`Qy()<`Ra=NwA%gj zFAEn_RGi0qKL`Xr>EHhX^sXF4MG^9(+y(}qQ$*hBx>ksVm)I7B6Cf8MP)afsm4Cl( z)yNU9$i)>=5(sc=z{!5cuIC{+G;wm<9F(-?j9m@ywEFq!2f9{b6zCLNriaKg9H=Wp z?6}0&9?zvupFT}m27>iPZ&RRLLkBq_r?@yS^||FYkBgaVXB>0mB3SK#K?NIe3%mDC zI(#7f+T2Xe3;mzoRqXMYaN@AAkO7&as9hI(#LUbLpdQ0=sA6zaLj!aB7tpz2b5J?$ z0@Q~t-#=y{1o_YMAsg0H*U~b0@23*>aIeg$+;Y&i;-Q=PX(Z2$?2gL2el)QTiM$nF z@y5OZ>dM(vIZ__EH8sGfQ~)Hv)w57WU;NzK8u<0#PZffE+K`me$!Ir#98Z1KO zL#~1jEGzaMvl}^xa|;S0%{$b$FF~+PaP#K6c1daJ;J|<~^86{f{j2E_@xiR~K%I_? zf?B~iyb;@&Hx9o|uIk~jkJc{&TQE=0DhFBRnh0s*n-Y(Hr`nVli$X+9aCBU4&v6o_$)EUrD`(TOVaKt}zax!+ z&Oe z&QDThyD0vmW0vB%PbdO&s@-u8C&6n{mc)>t8XKrWP=luVnYl_%Vlc%`1*?$Xl*jC$ zuiL+E`#bw5?_8%4vrZz_^9E<4hg-Y?(=nUtHMA)IgsERp2=adD<6CFgo%V`sy{@%@ z3U2+kG3{RRU-TK)`5h1Lm0w5e7Nj22LDXzg^_>oKv?fo!eveQs$!b{lPP~qKx7iH6 za{bV<&SSb+U@a9%NZB1APZ$(AY@R~vdFw;o`A(gIDi6iVr9 qcAaOrdH(hJL!uEf!0Siy*K7tnti(iC;Vl1X{)x&{yp0Z-ONl9YJAEX^B6^V%8GBL%=6Nw)}y`J*H z2SwF3yuEC)yf?hnQ6Wcv2tRDkhe(FaHfd7 z9ic;&Sab6RFGxwD#I6=SdF!E^7fVM@;E`9$^0Im9 z0Ru)}YpV%oN||gqT+IPB^SMLc=a zI5|0`!f;mH8a+HbzFp)H1+pytuQjKGh7%M8Ts>PYkPZpcQ?u+wYk~1 zo!nd?{RsiV$o#zD&BgwKz4KCAjD`NISFenW(ltw7cx*N~5pN7<-ptxpN+6+;zKiR^057ZhPLtvjh*=jzBa3ljLf6!85qp= zPnE^R#l-?uH5xw6{LGB^&4pu$_UvL0(QvXli-VI>k=fCJHLuT5iEfz;17S8?upXk8 zB`VCoAUQodI5?|R z6cnE<{tTrH^6~KnUazOn2z~%fkf>S0&dO@ZO#ynyo-}q9e3E6ge*WlN{)>ylA7*LF z8_UZ%P@K5l-7E%^tN?^<`Pj{<9IeSum(I_Qxm+){-moS`_U|GGn>&z;XRC4MX5kp& zRqL~pV5KQ%CebFsNH9>*Oiz2eyIU?7eYLlHT>S=;Ina_#SvopuP*G7`nsBHDaH_R& zP2knY$jI*=(J`9oP5Z{}Zf(8wS%_M_nkqN?@#6<>9L_`sghH-RbD`<|Ku^z878dz3 z8XMlU&dyHB+#_(%!cF132FspEoig3Rf`ZS5o^$n%o5PuWth_GxIP87+V~Ce$P4e;aIl1<$nNRSSq@tw6X}5hi8&_NM-h4tLBj~*7aOKbSWPc$5 z(y@xn=mt>c|NTnN&&dh8s-z9Pt!~e^vd2Ojgx?gWJia(sP=1_s_2do!L({+`{R2;+psllzn( zd5z}DoGlohq+7;uwCZZeY_WC}^pfGCg;v`PeVv-p6^p0GV0iu<))cTPj>N?WiP~6O zJ6rHvQc2@2oc=6Iga6oT*?;+-@bTk_`)NM7XsCYZ)s>f9)`w4@Fv?Sg7Zw(_lU95W zTc?@jtgH2x0&X~3&p%M=-dHgL04ehrE3yF-3%*3sj zO`T;A79vnEUY>-C%1B>dp5ys`ja6^#+M4x8$zU88#9?srlAh?z-j8C{OuM&l)mi_c zPFh-8e0=2*ZY&+z_ZFrO1Ag*{En78%Z(Z?4wq<2Neh{0 z1Fr|nIE{bOkwv}coyipO^w^(WKRkpw{NY9f>weeIg>u~A-}_!3l$VxjmQ7Bir^Uv` z9v>eY)jKr$UAw8M#3(=0D$y=4F9+w1j!)xvf4x~}Y3b$T+p6I1;Sq2;BnZzG(K9k) zON6}?5h*JxYri>YE>udn5X$?C`KB0uPI^!S#a$?4W5WjF^ky*CV!8fN>s`z5jTct# zA=j#kOC-w~85w{cJmERKY3u?5Q?A+hs6Wa}@{yIT#hQ2X#>VqjA|ea8G@=wdeC1(mR_IYXa~T&~!Ye!(B#d_EA#~CV zqimHemd?(!goLiEzi|RgL^%WFs;4rFxdzU?~g+h!Pym}ReOV!fa>Mgnmy7$NH0W#h{ zfJXUvc}>07ld7t!Iz!Qa=(FdlQxFroulJMM8O6x*S@#j-kChciy12XBJ3ALMv-0r7 z_pTma-zKr?m1?uvP2SyHZl5Zq`*}nU;Zx%=i;B+I9-BqGEq5RxMpHOV?5^q`y$moN zcl!zx?xbcegky2HK3;9y%*wAcZ1Zc>c#O#OT+Bt6mzU3}6m$KHT36cAVmz%|rdL_y z{Qmydy9@gWalRXa$OT-woes|uoBMXQ1G50PB-c6p>sQ64ogZ-V@%P4w@Yo)4bn*8) zBzG=ix@JvV2yc}E+h?1o?ipX&8b2hj*|Us|16*5&1MF>~~w2ED7zlD1Wup?UJ;j$&9fFkYU?|2|dlcR|J!vv8*}b5D1# zJWvaiF<}TrRe2eDW+#D+loaxF13H3{%lU;*gp3CZ&2p+lDjW`uj*~B!d~5NqfEM!~33x~)&`N>hd9Zw7(sDZjp|eKuY2;IttkawlVNewLGyBeztXq99zZ<`$Bs z8X1vZS6@HYzVkru^IAJpA}+D*eS03CUv^X2kx7+#aPXzcZvn5f$%=O-y%+m4x!t|J zRumhnt3Mp)>U&49{I5@5Bc{c-|3rQLGLB$kPTelh?}o0<&Kh>1Xj!dHPfu%MKSB=9 z&u0`%&Cbpa7MVEL8gp*?qPV_W*?zFH(rL}BL2`b4EHU_?^|PsIN9_xJqURY$&GBA& z!{ThURd{3Edw^2_uy^cTT&h;!!8ZcM=Sr$oImqAnhu>d@J3#Nwh|dj}h>pEPwHpB4 zC(zeDGtLn4lZm3?nLQ)xX=!Nzg%No1;&Y+Nbf_=Ncv&wT`k?$!I!X35BSZPY zXM5~vLH2i(=BfGc&jafZx0}#yrLpmG-;4)JnNa|)QihGn|IsE?&pXZ@^4Hu|nVFdp z381O91bqQ{juk3#wVoxUr&C4s{5`z}EiyVf>fC;J0Xim*T3FKY{aPQPkH$=`t(K%@ zNM_~&fG8+pEC0acNwiFM_jLqw79B0*XLECScCzcD1wSG|VO12#VMm{~HfP;EP=K)b#Xb00LE2+#(_(=;+~}KYs?Z zM@CM5xX?WP*@NysM{BHElhO6Y`lY|;i?o4*`dl;th z&{~FhY~mO`zl)aEIOx&y@N2|w<(uJ*5*^QTyRI-?LwrMdtdEkrl<*Im$z}o9>)C48{$jGbqD^LR~vwZ`GcxgGgz@tud zM@L75l*hXv?kP6nf0(htfZv>!Rq>}!3b+wM>i~WnQAF;`YQ+ZbZ_CygyNaw0+>*^L27l(iN zFmmY8)PO`OS*Y21^b5PBq-46%>~L@I%fR~O!NNda9|gbNgr1rI`PTO2DgbZ*4R>=( z_sQTMkBsD~vjBhM==cvfQn<`$2nftI3a95^P!0jG0%k@_Q!|!E%uqvvo-B%=QsvE? z&4p&qA2l^o6(&?fM3M_N7yW-SE?9(-Qr`k)f2Q=M&2JM0GQvDRKYwz1x&%bT@oF#r zW8M!vmclPz#!?A#iiu^K`Z&-3M1l%I$mGhN=E#8_q7Za44(ug?nEN4dK7a1LQ&PN~ zf>PhV!+eE@kDo2>@8jU`y`iBYJRCi$$IjWgva}R&ce$jNEuNW_RH$2aBNF>R1sJ!v zfj`?BBhb6RLP+L*?pk3X!HSpH*3p^N<1#WTLQ1vfOrfHs1*EjHx5pkYkA;m5t_c(( zd(ZD+zVXK!cx+sp975_a)FABR#}e(*HpOz8=m1fR(`C(@R$ROZAYH_9wzjC~Svh^AGBF_`AsN}l`Lr41oy1bA zK5!oSvN3M&-`6!Xw0rDRS{eZu_#OLXZfxuXfRAS7%cJXC;HIjrdMQPGLUkyD6k;PH zydUl^v9Yj#D%3JGROH0L#8gmF`1i*;)8hu&m)KS5>2u#S@_!mTsi+JA+oQ?>#KhWX zdZ_@!Hgk;waFs?4PNQ<4fqTTo!vnhRw`)sbA&}D_eBfo%j+q%5ve6VPfB%M9=o=Xs zDJVo#AeGS&hIV4$<2&2hUSGC9sF;a^@gyftNls2aI&!g_cqCtwl=Yf31@M@my85fu zFcnHZ8)a*2YXCC<3!I!Pj5z}l+r`kU<y1y%x#zFZSv(lWn%A)%o7 zrdx)MjqQ232<-C0-6#9$3IMwXHP)DQOdK5L`T3!K3!XDIuU%bTqsTZAH%kvRj~~zc z`E#{hoJ~eb8bb>efBLj%V4$p^!1{Cp*x;dw4IlwwFc>JigTM{2555Tda6BDDMpSZx4LP67$W-%!K()1K=qHmn%N= z@f96~n9mF0fIBd^4NePS4v&FH2KUNEDC3EU$Ir&bG=irjGASHJ$?@^WJ7txN0ENbj zRPyEGvR>GZ89BGsnRg=V=}ikRdWB+%I{c~PYQNRwOeroXU}9ndB=T^5wHj9^yE(zx z!4fHc+)bRDo2yuO0vshNDd_@Z=ZXusf6@$u*YI;7swPHE8D*nCD;5H=rp`hF2KQ&c z-ODfwpmGJR=SD`<)08jfoZI6M-+-&qVkf(VnddEFe*ATD;ek;4rM=zieQINGZ}00X z;(w(iU&EiZ{^`@FogKTK{xnk`Am0>~lz`BNNz?=OT^~rXva%{!F@X^lz${)QBqjoy zXaHPNt#52()EscI1i0Hj%}^Eh z4pm}A4in%FfF9=F`t{RmU{xkqK_WsHWnyfMGc(oGQ<$3@e2?%|Qc{Ww?Gt}lgOrS5 zCBZ5gQ{n`c=&W;#4mRZlpf3(X$L$jAUxo%`YM?eB}(O|al}v9hs6MMj=Uhh)TmLX_&3!N$8RtJO(#Sd-2= zus()}7o=x7!XEyf1+Eq0ezd|U*N>3S%gCTcWKx88<>K3+DAfP{0kHA*?yjT~cqG6p zXDu8A7~j%Q^YZe-!q95w5eNht8k*B=ZA1nkau;`b?gIdfZC?Sug0}!O`CCNpct2C` zXHq`_UM?PHJ-m~**?`E0golMCwA)%(SX4UhHU&7jF$oH!tC;nzxNI>SJ=CwSuN%7S zq(;ayot&Hir7{7R<7)L2UjiR-FY+p({Ft4bj7(Ne4iKNZ-sssFpdoP6+;=9PvksGU z8Uy`(wf6&Fq?*Z^2y=6D>&vZop`QNAJ~lJc*vr!dtlYLE(!|i$EHl|@XJD{%N8j@D z^5Q*tS->2F&y|~>pC69%m|>TQg5t_S?VU=ORQRfwy?t48vq&A_FiccrDKr8`Dn2n$ zQG`n}idboBDG8R$+qZAy?4_inNU>x9g;(ALGZ~#24EOgZ$TK~E{#>j=ML_{*XafU- zi3Fh8MMRnv5}%2k4)OuUeRd+iL-Z5gob>PeMlad&LFZd0-8c2tG`Ssy303lb?FE zjONRUynM+^fkP8VPC&rzwkF?=^MX=ELnA&aYM?uU_}WjrE(5*^yy7TmzGdc2nR( z02JCz%BG8fimavvf4@6r`+^do{Q652ITtTQbWapG9nrwR2Y~b_fyp%cvvmV=bH3om zniMfDZT5!`sO8=`DjD<2DH|IbKs_ukFPCT){}tjSil{Jd1b5H2uO3J$V8pQS@Unzl z84_W8pq35}eqYXZpkWUZ@8l{aUcs+44IlH z9vB*OyuZB$#UhUCL6tbW;^c>*s2_5j@loJ}(78|xS{znaR|6#gXdlE$6C=KcRh%G# zh6fC1iHZ1}y(MOVbOdpBP=v)-`hX^RHvVZvgaRi_HsAH#yLnJsclXl>VrFifE?4R< zZp6_?p;)CtP3m3^l1aW77GUaRWo31B$D3NWv3S^D8<3$`MO=rO8PEb0* z3D&r0dgEw8us_=#2o0e_LPEN}zMe4mV@)c}$yo*tSuU2E9)Ef@0O;+ab0kPkB_y$D zoA%)fT^Rj~Y(9veCd||>_bfH+{Xln4Uh3kY)&> z13QJ=BWUCz_BlB7$BUm{Cx&_ zbryn*6O?<|v4+BQf)qDZ&UME&sj0F^pQyufEm5LvNg<0irB(M=r~*(1;|>-Igu9jz zJmqsP7d85KaT~3 zNM}GW6QLge!seoe22giZ0RSooChKTZd959VGet@D?zRgb>URQTo|qJZEXN-N!%Vfn z)C|MnWD3=99D2$1)zWW3|n?WJ(leMq>_NrN;3YfrA7%6$VoXELY3f7Q?4`gKU@?uY5m>?um_+=Lw)@s2*JDMxznsg z(rnXFxLsykJfBjjQbMNA#JM3ka;fF9#ujCaF_2f`AbkMI>4n;W!>>QpRsa%iM93hi zk8#+rgU&7ITYaYi)UN#c2*OT)A5)8QAwxFM^9ilPwi_ofm!On^a_ErLkP@B$c2Pk+ zp|b3d@6?0%I6-wGvD6TKo>W>22pifd3W|BFd4M_Odn#!Q{@}XXKb!2_c3lKS?vEc@ z4DrT|Zm)s-3 zI6RP|r@wi0iZWnbqgAZ>+Qx>AghX3UuleUsc3=U9zVb&zM1TJY^ecOQ% z0#y1!qq`huN*tBoaBTsSIp24O0COP9!FFJDZcc=reg$-f>@zY*`nVOp%v2hPEQDg2 zpFZ7w9%}z5gYSaa}P=L8=w8YYCt?8A;{(EEx@-~)BlUTyt9>4 zcR@7{!m#Lr#j z%?(I#YA~#l&r!%0qCa%g0fCtOM>A6VYgGPFi`Ph1rvDDo#VOMLFXsI3uKxe+H+@0b zKsm@CIwx|Rt_eGSUWm&LSL>FQI8BuWOC(r9fh_7-q5`Kz*(5{!w~C5bI*jg(9J0@* zt61ETdCz(W;cz%ulYy;O7dLh9kn1OL!I7~WmY>EI?d{N%0n!L*%@XZGO|Yp0VPLL0 zOUeKn8{4{@u%*<` zXiO|EWkZ})e?cH)vrdkWEvofpX)$Hel%GZBF{@_u4Gb`1`~s%|mZI9iovtnQ^#WX6Tu`W)Uh4;o zKfu|SR045GCE&m<@J#p1;$uFWXj-VLy@kL39as!-l;Ze$dp9;V+OFGR#r0z2-~iqD z9c+FaLQ;APrgJD$2TkGPHx9gfIP9Rz&6Qi6hlh?!Nl zW&?pIb#*H#kaalFhUSXU6jK*uMK*2ho z$!$H-Ru1$E*rg7@V7WEVB?x%aqOs@_VL(NbVD*4_qgXjj($s_DuG@0FOr3=Q6@acJ zJK79dnP$n^2l{Ev@;coZTZQfYJBU!MyAZXrvvZPSQg*hQz~&aru8EWXaK2GXLnAnk zr8Plf6uR&spT!VJnyIOczkmON5RNP=zIQlbJjy~}SvgvssUHrPV@JC_(t6m(X`B+E z!7k%?-ipRwk;kIAVO9DpxAN)RKJGR2aw=$ytki3>v-T$1C^`)4-fB*HX!YQkC4~Tp z20T440ZDH^Tt1}2*4^q1_t(}6n4=RD6AjDjgLmI_{~DX~rI*mq=1c)w0KT+A5MZ#X zjq@GT-u&VYp#w(jF(IJ@0jdH2D4*b+y{EMW1_lP*Zr;??6pedJqk)Z&0sGYL^=W8G z2uR|0cXkM{rRrzEQyetYCtjj8ljzl?3{eV|5|1b(K7&pmkpVZ8hLEt+vbyOF|B2x! z9PSKw5`;+$T;#rDSRi4;!SMr9iU+6J=bW__%{POcyH%9Nd3&sIad8nWYf4K>B-qtV zOfro*FHcW*vx>b%v%naE#|b(fL%q=|F?0PL{9>}`1AIcRKy!+<~n z>-T2QLonSE>`5QblquhC^M6ZRMSA5(m${ zj9ZR_r#^q9SFa-ylT^C8$&G#a;A_2NZHhv;)R8Uz0-dx2Y&LA`ciO=zb)e#h&Bzx5!Mg+Rt6U`WrF+YlYy1V_uJ z7PeO_hLQ-NR{Le98M_WNv$_o+B=sX%E1ix-5;dw3K1-b9BbJmP5ob5WKY*H&i+^S$TH zZT3#M;20z*fTbhLMCY4iOsz+2Z$(){$dqMG(m)TAa4RH@o8;OCkJle+*$UDUga}rL zP1-Th*9iK+?VU$gCa|t%OH_$cL)v@NKM#bju9onJ#B_!^@ISk+<3mNCn70Wbn=cG% z2BlFD?ZjqegyXnNu*&r99Ny~(Ts4FT7GE){A?QT$5?*e{X=b`NglGN~qJuwz-hO@) zaR^5;Z-xiEQpdA?_O)DV)?6DOc<{x$t@6Crx4uSYn%G~7an(XU8FyT3b;p;o+eZSA sEK%C-VO4FQ7pN{>S^O``Cr~;ML=Q7O4GIW-`R@e<8CB^DN%N5Z1G|$&BLDyZ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fe741149b5d06e9c8bdf2b93688422db8c207705 GIT binary patch literal 9537 zcmd6Nhd-5p-}b3Qwjxe43Zcl}o3dr^y|PC($83=uvS&CUWMyZRP$4TjJLA}UkLT-o zpWo+w-v8jeKOdYA=f2N`j9PVgqcxlmCAgaEu4)3*;j*bpnV#o9|4uXEW zO1sLs+H|S+Su}HD6$_h(Cr*DyzxZq$2cCKmv^1XXjlIMnP9Y+=irk^3Y8Mq1Jv}{5 zPfH^pFVkfe%UqwI4>n6uKz}SKh>>Gpkk_xy!K`YnRogZub=~H+B6!e%3|^gRsPEWN z4U$|ZxrQ}8J?%a&t){7{7_NZsv#Si8SL7QT8PU5#G9~bPVf*FFm;Qc!PUCe0qoa28 zOr8Dx+#DPn;^MT@$Z6Nw-rnAuH*XRW5)L|gF8wJWe;sm}0QEdNT18crMDk=L_@m~7 z!otGaw{Lq0rJJo;R)~s-U=tvl7q;PsvvYDfIy&y%kT1#PR#EBh>FKGf6SBgzawz5W z{+Qe9=}{$UDPn1{8CO2tEeUKb&B5k=k9cW3H8&?sf+Zijzdl(~T+HhkUf7RexFK(3 zlwMNeY-)N(l4N3HqQN+**3d~#PVUj8mULk+A+`p$1tLk3yLaz)b#+ljw)I8cu78rm zY0+k1(tv#xIs7U+J3A{YYjkupA%UDnmX$pGF&#fYf8BM(_o{MZYGhhQM#ZYketv#y zdV7LSc1YS2`q5D={}`@rORnzWXS6YIU6)<6bvEZ-rJ5HA{+% zi%U(tZTBlIEbPJk`!PQ)#~b%C+Lg-NhbKozsQI+8Fua!>#I#~$;jQG3d}3k&10S_m z$qW0@lYZM>@hmz*zc2EqVQ*ikfSz%!jfc;xtn5S)jBjFNW83jij@kOH-NnVlWzqSR zt5{-O(&V#kWn;rENmA`J{_EGTqun7BVWaDK9E(pVBR^zi1+|(NXg+u;%^*Mc=g$vI zt_EZ0p5d|j?tplOr%eH;N+H*;VNq*XcJ`}?JF23@&kknY7JesJuzkTNL?vq%qPOeh z7$`@SnaQ#RcjC;(?#BK6ZqAwN_<75sVuUW{N2mSSt5>h~Cj&Ft8W`lAhD*P8_TfV^ z6W+chF|L<@97f6`A_z25{EnmU^PRWC>8|WI zHwXW%b;HpzNpGY#6V6q)wY9}eeSIk#frL9%y=?%GsM_IXYYQ>pb<+@`A#RXA!sKOI zSz8-Wk}|{DJiNR{^`3+kIO+wO?^O?ujsOTWoqC1e-w6GFlDgL|cDyp~HQDOocY3rt zKe^Av*|>^>5Mk?~$GHHR|MxHz-rCyAKfm|@l(0|~V@WsF#Q9;8+0Z8=_wxH;sI}S! z6uWuW{;hvFDS!@zg}$9J5Pl2>6GB7{#bWE=@W-yQxVU(3ZZ1zHGhn@8KbYXwR?@6R zI4U2}oi3bMt7mYgzEXxBV*7Z_;wsX|q1UubmqokC#?FrG`mM0+zP`SdrLkY-(KMw1(RLF(w)3Ad1XIpcv$zODZ^*}RwoSu6H|`M4RdpI0x_Zey*(@K z;EVTHuf()W`|Xc=b@q)74ps%69wj9u!S~vln#VFSY({mTb91ql3$w7X6VwU^u3o*m zb08{ew>i_=X~JKinF4Rl%v9;V?^NxvJh-#tUa23s>+)g!x$uOJ$ni=AlXBWysHjL8 zx){r+Pg~pCxO4 zp*H7p?16$_YiUZ18V};V)+fdr{e7Q3+sKl9nIx?L#k&mi84CwzeYCPLJ6kHRzt_a7 zuBHZ_(s{UadZsm8OG`@utzJ05$IE+@jBI7Bdi`)AlmAWVA!c=pv1@$%w77ag$ZcN9 z!=qBYzyUh2sOaSEc%6b(x5qZxPCG5#$$=CTN}1F?%ge*V!7u#u7a8|Z*YeVaeo8-U z7p|z6dR-pEq|xsXDv+8xU%fy{N$K+^MAOzUmi*gi%23|KyGG5ANxlPu31e#)XhMQB z4zGHDe{(%DGLl;5@cp9@rFL;9rVyXy0i&zlqn~YEYyS?**xB1d*lbF}Zdxx~-+rV* zF%?S6#8qwE>*1R5v&CLoxq^pj=dU~_!*{C8qW!~%51wh=3)>JL$09d7(hpBkJx>l? zV&p7Yn~U@+h-k$kP>4MLgXs{r`A!THo~GEOX`<7pzNQ8;!dXAfHyn;DFE2kR>J5N}$QHlqAk&f095f$yd^IJ|sLqlIbMVEDNcNbdkq1*UKg>}FE z;o+ez4<$-XQL$qO)1RK6-tgrMpTn@}hOl)uTr`Z~C$IhB!SOLZ)(h(lAFGyNLgwLw zhXzDc0vo?rN*+r~@2*c~woJciodG~nOl0d_UfMmnbOl@YoiVA_tHeYdMMZpWwSt0z z%g-g4$=LMD9~Tbvh0}<5H&f}1E>&3A225>w!z>-#JvWbO@ZIOyZ#4aSd3&gf+ts_c z!&ZCYRffR*aqXbwE@ZdpK9yTWIH1qD1e48(p>8jVJ`4eB@g3Az_zQbA6hZl-v8`yp>5B8ccg4&p&7Dl zy7sAmsRL;dGE|pl<)OS78rLV9*yCDz@*{?RCs&g8#oF2$x#PpTcb}h`Fvgv3XP#TK ziTWS$oCfOZ4mUr)(tB>v7GZ`xR`12`<_f@LwPsW}5U@95-IgGB_%AM%-?8i_bqPeURs&sJ!?-&V0@y zcWcYF!B|Bl`%;08&6L$P+-;uon(ZF7& z6NqvitLjQ)sb<3&<)G!p;o;#yMzmXV)G%(>?EW3WJjABX33>fm^oZ}Y=$GwyEGcR+ zSGQJQLxa?VKX4vl&N6O`IUVAGeRuf)&qf@;`sa&4aV-40J32A9c zB#$K|nxWch9{+Qh&>v70RPF&i6O zr>Uf*q?J?8@O#S%cUztm{t54v_V$o2t62dW5|W_ZllL1tJ3Q&kZrJOK#`&8otJRYe zbsqLA>&uWn=-M4iZ||DJ?&tOVdAkbArSC60^mKc)hd79zpKzZx0eiTHRpWBJxBBQV zA+})}rv-e}8{+90i+U zGwSEBcott2#e?`I+VcY(H)Sg;tGrp0S%Hj<3@2rlL=VBPrL?I#SCbBf!!q3J>gu=_ zRz@m98b?UOTj6|f3_9`4moEZTQL4-Y`1s+`(SZx;Udowb{);_#QN@o!h&aQ4)p;zd ztE;D_r5)|BpKrF)dif1}RqvEc%k-YX-9a>ujrsnJy~jw|&?#SnTko|VaIkS#wAOQV zbjT#I!K5hwDl&YmHyi+)VAPa`>9y1*aFDSZ zSoPF(b!+U0*xW{+7hSX#mO)^AU#CIV+37Jittr<-gDQg8KBv1w^0B#fu1kOJ&nJi< zEz4i&o{ymVT^|!P|J$Y<8SfppPrk2x-Jdx7#_jK5M!ulPMd~2>FA!9~l zR<TeSDdccI)%k;Rx}B<*hiz91FE+70Nu|Eyvq5pag0yOZAgA)K-!WhGVO;xt$upgF}HPaa6l}6 zQd(O(;{IcB?|NIECBU)}lL&Tn!S&@X{z=2nHod^^Qc_bReavkQ{JvX=&+&-{}U`PDZY-tCFWOCRHVch2fwVU zs`A}mdunZ6Vpz*XNB5%f>1r%ma8=Fqa%mqxa8e@k_+b|gBFn(r$7j68W%hJ`l2#P8 z6Ljec)LnRZN4{DP7dLk@dVjtv^;z?!QbFv8m(o!O8`JOyl<)@xq67b#np&@his)5Y zB7q0>v{S-DLa42OKSM)9&(6-o#KfqG@&HP*vuC2k&jft7EImD|l=GHX1unGbe-4Sv zr~Ab{(QjJKuVQ0|wVKmJ=K+6Jw}qH``8znK9+}1-`USYUx+*C})YsP=vUS7p`!|f? z@$ya3gG!lVqJo0AmI;l#tZv=Bi5{@4XQ||C?e2E&OyUd(2oQ*ZiU1yT6$^`iNIFNyyZ!*TOixlYO-!77q+>jQV1cx0Je#GkZ8 z+A%WXGhAv0{RoZnfELLq`Z|~5`*ZvRzEA77 zA!%$p@jG2OJUqM?D&S2-Lg$E&V*$P(I6Gd(MxU0KmV_*SkPs6SzqkW68XguF z;i0?xMweCF-QC^7Vz%jQmwo;O`qS6jn}?52Mpm}JflVTIb%zyn4O%gAer~Qe(~O>8 zCgM$GWLHOrL7ls;lhY>!Gz!?T_{m1g&dv@%N8eXIr@zcMLRE*@T0)3bU?dX~4q)72 zjtG0Mtb&XHy78*$q5Lq!4i|no*s!38Y-%^j0_r5(v~?_5D*|dmFIg@WyG1NM7%fP z1gHZSm(AVfAws9O�oq!sj{->t1BYS&$7PnIT7&#ul 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..86fe856aff0ed081d05f1940220a2f8dddb2321e GIT binary patch literal 10255 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 BeatSyncMessage: + return BeatSyncMessage(t_us=t_us, bpm=bpm, bpb=bpb, beat_in_bar=beat_in_bar, flags=flags) + + +def _tempo_change(t_us=0, bpm=120.0, bpb=4.0, beat_in_bar=0.0) -> BeatSyncMessage: + """A sample that mod-host sends for a bpm or bpb change. Its phase is not + correct, thus the grid must take the tempo only.""" + return BeatSyncMessage(t_us=t_us, bpm=bpm, bpb=bpb, beat_in_bar=beat_in_bar, flags=BEAT_SYNC_TEMPO_CHANGED) + + +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_tempo_change_sample_does_not_flash_immediately(self): + """A bpm-change sample is not a crossing. It gives 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)) + g.tick(now_us=1_000_000 + FLASH_US + 1) + g.on_anchor(_tempo_change(t_us=1_250_000, bpm=140.0, bpb=4.0, beat_in_bar=1.5)) + state = g.tick(now_us=1_250_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 + + +class TestSetTempo: + """A tempo change that arrives without a beat_sync. mod-ui's `transport` + path (a tweak knob, a tap, a browser) writes the mod-host globals but + emits no clock sample, thus the grid must take the new rate directly.""" + + def test_set_tempo_changes_the_rate(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) + g.set_tempo(240.0, now_us=1_000_000) + # At 240 BPM a beat is 250ms. The next crossing is at +250ms, not +500ms. + assert g.tick(now_us=1_000_000 + 249_000).is_flashing is False + assert g.tick(now_us=1_000_000 + 251_000).is_flashing is True + + def test_set_tempo_keeps_the_phase_continuous(self): + g = BeatGrid() + g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0)) + half_beat_us = 1_000_000 + 250_000 + assert g.tick(now_us=half_beat_us).beat_phase == pytest.approx(0.5) + g.set_tempo(240.0, now_us=half_beat_us) + assert g.tick(now_us=half_beat_us).beat_phase == pytest.approx(0.5) + + def test_set_tempo_reports_the_new_bpm(self): + g = BeatGrid() + g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0)) + g.set_tempo(90.0, now_us=1_000_000) + assert g.tick(now_us=1_000_000).bpm == pytest.approx(90.0) + + def test_set_tempo_does_not_anchor_an_unanchored_grid(self): + g = BeatGrid() + g.set_tempo(140.0, now_us=1_000_000) + assert g.is_anchored is False + + def test_set_tempo_ignores_a_non_positive_bpm(self): + g = BeatGrid() + g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0)) + g.set_tempo(0.0, now_us=1_000_000) + assert g.tick(now_us=1_000_000).bpm == pytest.approx(120.0) + assert g.is_anchored is True 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..323f9ab0e 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -1,14 +1,18 @@ """Unit tests for ws_protocol.parse_message.""" from modalapi.ws_protocol import ( + BEAT_SYNC_NEW_BAR, + BEAT_SYNC_TEMPO_CHANGED, PatchSetMessage, AddHwPortMessage, AddPluginMessage, + BeatSyncMessage, ConnectMessage, DisconnectMessage, LoadingEndMessage, LoadingStartMessage, MidiMapMessage, + OutputSetMessage, PedalSnapshotMessage, ParamSetMessage, PluginBypassMessage, @@ -263,6 +267,75 @@ def test_transport_malformed_bpm_is_unknown(): ) +# --------------------------------------------------------------------------- +# beat_sync (beat_sync {t_us} {bpm} {bpb} {beat_in_bar} {flags}) — 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. flags gives the cause of +# the sample (BEAT_SYNC_NEW_BAR / BEAT_SYNC_TEMPO_CHANGED). +# --------------------------------------------------------------------------- + + +def test_beat_sync_basic(): + assert parse_message("beat_sync 1234567890 120.0 4 0.0 1") == BeatSyncMessage( + t_us=1234567890, bpm=120.0, bpb=4.0, beat_in_bar=0.0, flags=BEAT_SYNC_NEW_BAR + ) + + +def test_beat_sync_zero_beat_in_bar(): + assert parse_message("beat_sync 0 60.0 3 0.0 1") == BeatSyncMessage( + t_us=0, bpm=60.0, bpb=3.0, beat_in_bar=0.0, flags=BEAT_SYNC_NEW_BAR + ) + + +def test_beat_sync_fractional_bpb(): + assert parse_message("beat_sync 1000000 90.5 7 2.5 2") == BeatSyncMessage( + t_us=1000000, bpm=90.5, bpb=7.0, beat_in_bar=2.5, flags=BEAT_SYNC_TEMPO_CHANGED + ) + + +def test_beat_sync_new_bar_flag_reads_as_new_bar(): + msg = parse_message("beat_sync 10 120.0 4 0.0 1") + assert isinstance(msg, BeatSyncMessage) + assert msg.is_new_bar is True + assert msg.is_tempo_change is False + + +def test_beat_sync_tempo_change_flag_reads_as_tempo_change(): + msg = parse_message("beat_sync 10 140.0 4 2.5 2") + assert isinstance(msg, BeatSyncMessage) + assert msg.is_new_bar is False + assert msg.is_tempo_change is True + + +def test_beat_sync_both_flags_read_together(): + # A forced tempo change that lands on a bar line sets both bits. + msg = parse_message("beat_sync 10 140.0 4 0.0 3") + assert isinstance(msg, BeatSyncMessage) + assert msg.is_new_bar is True + assert msg.is_tempo_change is True + + +def test_beat_sync_without_flags_is_unknown(): + # The four-field form never shipped; it is not accepted. + assert isinstance(parse_message("beat_sync 1234567890 120.0 4 0.0"), UnknownMessage) + + +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 1"), UnknownMessage) + + +def test_beat_sync_non_float_bpm_is_unknown(): + assert isinstance(parse_message("beat_sync 5 1234567890 notanumber 4 1"), UnknownMessage) + + +def test_beat_sync_non_int_flags_is_unknown(): + assert isinstance(parse_message("beat_sync 5 1234567890 120.0 4 notanumber"), 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 +458,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..4062d98ab --- /dev/null +++ b/tests/v3/test_footswitch_state_view.py @@ -0,0 +1,315 @@ +"""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 unittest.mock import MagicMock, patch + +from common.loop_progress import LoopFill, LoopProgress +from common.parameter import Symbol, Type +from modalapi.plugin import Plugin +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 1") + 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): + """With no transport and no tempo there is no beat. The border shows the + full ring 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 + assert progress.mode is LoopFill.FREE and progress.segments == 4 + + +class TestFreeRunningGrid: + """With no transport, beat_grid runs free from the tap. The beat reaches + every renderer, but a free grid has no bar. The border thus shows a full + ring, and it keeps the beat with the tap LED.""" + + def _free_run(self, v3_system: SystemFixture, make_parameter): + """A 4-bar loop in playback, 120 bpm from the tap, no transport.""" + handler = v3_system.handler + _bind(v3_system, [_loopjefe(make_parameter, "loopjefe_1")]) + 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() + tap = v3_system.hw.taptempo + assert tap is not None + tap.set_bpm(120.0) # 500ms period, 50ms flash window + tap.anchor = 1000.0 + return handler + + def test_border_is_a_full_ring_that_keeps_the_beat(self, v3_system: SystemFixture, make_parameter): + handler = self._free_run(v3_system, make_parameter) + fs = v3_system.hw.footswitches[0] + + with patch("modalapi.modhandler._now_us", return_value=int(1000.025 * 1_000_000)): + handler._drive_footswitch_leds() + on = handler.footswitch_loop_progress(fs) + with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)): + handler._drive_footswitch_leds() + off = handler.footswitch_loop_progress(fs) + + assert on is not None and off is not None + assert on.mode is LoopFill.FREE and off.mode is LoopFill.FREE + assert on.segments == 4 and on.position == 0.0 + assert (on.pulse, off.pulse) == (1.0, 0.0) + + def test_loop_led_blanks_between_free_beats(self, v3_system: SystemFixture, make_parameter): + """The METRONOME gate follows the grid, not the transport. Before this, + a free grid left the loop LEDs solid while the tap LED blinked.""" + handler = self._free_run(v3_system, make_parameter) + fs = v3_system.hw.footswitches[0] + fs.pixel = MagicMock() + + with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)): + handler._drive_footswitch_leds() + fs.pixel.set_enable.assert_called_once_with(False) + + def test_free_beats_never_accent(self, v3_system: SystemFixture, make_parameter): + """A tap gives a beat, not a bar. No renderer may show a downbeat.""" + handler = self._free_run(v3_system, make_parameter) + with patch("modalapi.modhandler._now_us", return_value=int(1000.025 * 1_000_000)): + handler._drive_footswitch_leds() + beat = handler._last_beat + assert beat is not None + assert beat.is_running and not beat.is_anchored + assert beat.is_flashing and not beat.is_bar_start diff --git a/tests/v3/test_hardware_config.py b/tests/v3/test_hardware_config.py index 7edd9ca0f..bf97bf2b3 100644 --- a/tests/v3/test_hardware_config.py +++ b/tests/v3/test_hardware_config.py @@ -358,3 +358,41 @@ def test_encoder_type_transition_rebinds_volume(v3_system: SystemFixture): assert enc.type == "VOLUME" assert enc.parameter is v3_system.handler.volume_parameter + + +# --------------------------------------------------------------------------- +# 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(hw, 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 + + overlay = config.parse( + {"hardware": {"midi": {"channel": 5}, "footswitches": [{"id": 0, "midi_CC": 60}]}}, "" + ) + hw.reinit(adapt(merge(hw.default_cfg, overlay))) + + 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 88b1091e8..cfe92fd95 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -5,6 +5,7 @@ from common.contexts import ControlClass, EventKind, MidiCcEffect, ParamEffect from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol from tests.types import SystemFixture +from tests.v3.test_hardware_config import _cfg LOG_PORT: PortInfo = { "shortName": "HP", @@ -14,6 +15,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 +329,209 @@ 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 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] + + hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": {"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] + + hw.reinit(_cfg(hw, footswitches=[{"id": 0, "longpress": {"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 @@ -641,3 +865,56 @@ def test_v3_midi_learn_moving_footswitch_binding_clears_old_lcd_display(v3_syste assert w0.action is None w1 = next(w for w in lcd.w_footswitches if w.object is fs1) assert w1.color is not None + + +def test_v3_two_plugins_sharing_one_cc_both_keep_their_rows(v3_system: SystemFixture, make_plugin): + """Two plugins mapped to one footswitch's CC (a ganged stomp) each keep a + row. Learning the second must not evict the first: unmapping either one in + MOD-UI has to leave the other still driving the switch, not a dead switch. + + Which of the two a press acts on is graph order, asserted here only to pin + the choice as deterministic. + """ + import pistomp.switchstate as switchstate + + handler = v3_system.handler + hw = v3_system.hw + ws_bridge = v3_system.ws_bridge + + assert handler.current and handler.lcd + + fs0 = hw.footswitches[0] + binding_id = _binding_for(hw, fs0) + channel, cc = binding_id.split(":") + + def param_rows(): + rows = handler.effective_table.layers[0].rows.get((ControlClass.FOOTSWITCH, EventKind.PRESS), []) + return [r for r in rows if r.control.id == binding_id and any(isinstance(e, ParamEffect) for e in r.effects)] + + delay = make_plugin("delay", bypassed=False, has_footswitch=False) + reverb = make_plugin("reverb", bypassed=False, has_footswitch=False) + handler.current.pedalboard.plugins = [delay, reverb] + handler.lcd.link_data(handler.pedalboard_list, handler.current, hw.footswitches) + handler.lcd.draw_main_panel() + + ws_bridge.inject(f"midi_map /graph/delay :bypass {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + ws_bridge.inject(f"midi_map /graph/reverb :bypass {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + + bound = [e.plugin for r in param_rows() for e in r.effects if isinstance(e, ParamEffect)] + assert delay in bound and reverb in bound, "learning the second mapping evicted the first" + + fs0._on_switch(switchstate.Value.RELEASED) + assert delay.parameters[BYPASS_SYMBOL].value == 1 + assert reverb.parameters[BYPASS_SYMBOL].value == 0.0 + + # Drop the first mapping in MOD-UI; the switch must fall to the survivor. + ws_bridge.inject("midi_map /graph/delay :bypass -1 -1 0.0 1.0") + handler.poll_ws_messages() + + survivors = [e.plugin for r in param_rows() for e in r.effects if isinstance(e, ParamEffect)] + assert survivors == [reverb] + + fs0._on_switch(switchstate.Value.RELEASED) + assert reverb.parameters[BYPASS_SYMBOL].value == 1, "unmapping one plugin left the switch dead" diff --git a/tests/v3/test_reactive_parameter.py b/tests/v3/test_reactive_parameter.py index 77c473927..abeef8e43 100644 --- a/tests/v3/test_reactive_parameter.py +++ b/tests/v3/test_reactive_parameter.py @@ -198,6 +198,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..327bbfa3a --- /dev/null +++ b/tests/v3/test_taptempo_led.py @@ -0,0 +1,241 @@ +"""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): beat_grid + runs free from taptempo.anchor + bpm — on for the first 50ms of each beat + period, off otherwise. The free grid has beats but no bars. + - 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 + +import pytest + +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() + 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() + 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() + # 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() + # 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() + 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 + + +class TestLcdBorderSharesPhase: + """The LCD tap border reads the same phase the LEDs flash on. It must not + blink from taptempo.anchor while the transport is anchored.""" + + def test_anchored_border_follows_beat_grid(self, v3_system: SystemFixture): + fs = _find_taptempo_fs(v3_system) + _mock_fs(fs) + _enable_tap(fs) + assert fs.taptempo is not None + fs.taptempo.set_bpm(120.0) + fs.taptempo.anchor = 1000.0 # a tap phase out of step with the grid + + with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)): + v3_system.handler._drive_footswitch_leds( + TickState(is_anchored=True, is_flashing=True, is_bar_start=False, bpm=120.0, bpb=4.0) + ) + assert v3_system.handler.footswitch_tap_flash(fs) is 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) + ) + assert v3_system.handler.footswitch_tap_flash(fs) is False + + def test_unanchored_border_follows_taptempo(self, v3_system: SystemFixture): + fs = _find_taptempo_fs(v3_system) + _mock_fs(fs) + _enable_tap(fs) + assert fs.taptempo is not None + fs.taptempo.set_bpm(120.0) + fs.taptempo.anchor = 1000.0 + + with patch("modalapi.modhandler._now_us", return_value=int(1000.025 * 1_000_000)): + v3_system.handler._drive_footswitch_leds() + assert v3_system.handler.footswitch_tap_flash(fs) is True + + with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)): + v3_system.handler._drive_footswitch_leds() + assert v3_system.handler.footswitch_tap_flash(fs) is False + + def test_no_tempo_yet_stays_lit(self, v3_system: SystemFixture): + fs = _find_taptempo_fs(v3_system) + _mock_fs(fs) + _enable_tap(fs) + assert fs.taptempo is not None + fs.taptempo.set_bpm(0.0) + v3_system.handler._drive_footswitch_leds() + assert v3_system.handler.footswitch_tap_flash(fs) is True + + +class TestBpmChangeReachesTheGrid: + """A tweak knob or a tap changes the BPM through mod-ui's `transport` + path. mod-host updates its globals but sends no clock sample, thus the + grid learns the new rate only from `_adopt_bpm`. Without that, the LED + keeps the old rate until the next bar heartbeat — up to 4s at 60 BPM.""" + + def test_adopt_bpm_retunes_an_anchored_grid(self, v3_system: SystemFixture): + v3_system.ws_bridge.inject("beat_sync 1000000000 120.0 4 0.0 1") + v3_system.handler.poll_modui_changes() + assert v3_system.handler.beat_grid.is_anchored is True + + v3_system.handler._adopt_bpm(60.0) + + with patch("modalapi.modhandler._now_us", return_value=1_000_000_000): + beat = v3_system.handler.beat_grid.tick(1_000_000_000) + assert beat.bpm == 60.0 + + def test_adopt_bpm_keeps_the_beat_phase(self, v3_system: SystemFixture): + v3_system.ws_bridge.inject("beat_sync 1000000000 120.0 4 0.0 1") + v3_system.handler.poll_modui_changes() + + grid = v3_system.handler.beat_grid + half_beat_us = 1_000_000_000 + 250_000 + assert grid.tick(half_beat_us).beat_phase == pytest.approx(0.5) + + with patch("modalapi.modhandler._now_us", return_value=half_beat_us): + v3_system.handler._adopt_bpm(60.0) + assert grid.tick(half_beat_us).beat_phase == pytest.approx(0.5) 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..607e4893c 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -17,15 +17,16 @@ from __future__ import annotations -import time from collections.abc import Callable from typing import TYPE_CHECKING, Protocol 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 @@ -37,8 +38,6 @@ class TapTempoProtocol(Protocol): - anchor: float - def is_enabled(self) -> bool: ... def get_bpm(self) -> float: ... @@ -61,6 +60,16 @@ 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 +# 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 +97,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 +109,12 @@ class FootswitchWidget(Widget): num: int | None is_bypassed: bool taptempo: TapTempoProtocol | None + state_label: str | None + progress_fn: Callable[[], LoopProgress | None] | None + tap_flash_fn: Callable[[], bool] | None + loop_icon: bool _pulse_on: bool + _progress: LoopProgress | None def __init__( self, @@ -105,6 +124,10 @@ 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, + tap_flash_fn: Callable[[], bool] | None = None, + loop_icon: bool = False, **kwargs, ): self._init_attrs(Widget.INH_ATTRS, kwargs) @@ -116,7 +139,13 @@ def __init__( self.num = None self.is_bypassed = is_bypassed self.taptempo = taptempo + self.state_label = state_label + self.progress_fn = progress_fn + self.tap_flash_fn = tap_flash_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 +183,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 +211,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,23 +307,87 @@ def refresh(self, box=None): else: super().refresh(box) - 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(): + 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 - bpm = taptempo.get_bpm() - if not bpm: - # No tempo yet — show steady amber - if not self._pulse_on: - self._pulse_on = True - self.refresh() + + 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.FREE: + # There is no position without a transport. The ring shows the beat. + ring = glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP) + ctx.surface.blit(tint_mask(ring, lit), at) return - period = 60.0 / bpm - phase = (time.monotonic() - taptempo.anchor) % period - on = phase < period / 4 + + 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 on the beat phase the LEDs flash on.""" + changed = self.poll_progress() + on = self.tap_flash_fn() if self.tap_flash_fn is not None else True if on != self._pulse_on: self._pulse_on = on + changed = True + if changed: self.refresh() def toggle(self, is_bypassed: bool) -> None: diff --git a/uilib/glyphs/__init__.py b/uilib/glyphs/__init__.py index 1eee0d841..204d23642 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 @@ -51,6 +52,7 @@ "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."""