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 d2e81f3e6..93d93fb50 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] = {} @@ -514,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 @@ -537,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() @@ -552,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: @@ -584,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: @@ -869,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 @@ -1209,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 @@ -1228,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 @@ -1236,6 +1460,31 @@ 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.""" self.audio_parameter_commit(param.symbol, param.value) @@ -1246,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 @@ -1417,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) @@ -1861,9 +2131,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 6a8b22a7e..21c728f51 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 from common.util import TEARDOWN_JOIN_S @@ -49,7 +50,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 @@ -59,6 +64,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 @@ -237,7 +251,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]}") @@ -246,6 +275,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: @@ -320,6 +363,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(), @@ -334,6 +381,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 f1f0f14bd..58d125053 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 @@ -603,13 +606,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, @@ -618,6 +618,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 @@ -850,6 +858,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. @@ -862,6 +901,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) @@ -873,9 +914,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 "" @@ -888,10 +935,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() @@ -905,16 +957,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 68831761a..38fa4e8b1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,6 +158,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 @@ -176,6 +177,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 000000000..a3fbd43ed Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png new file mode 100644 index 000000000..17f33709e Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png new file mode 100644 index 000000000..d4adfb23f Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png new file mode 100644 index 000000000..c7ad6c09d Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png new file mode 100644 index 000000000..c3875d459 Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png new file mode 100644 index 000000000..7ee2dcc87 Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png new file mode 100644 index 000000000..58dc7677d Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png new file mode 100644 index 000000000..dade80827 Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png new file mode 100644 index 000000000..fe741149b Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png differ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png new file mode 100644 index 000000000..86fe856af Binary files /dev/null and b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png differ diff --git a/tests/test_beatsync.py b/tests/test_beatsync.py new file mode 100644 index 000000000..d470292f9 --- /dev/null +++ b/tests/test_beatsync.py @@ -0,0 +1,287 @@ +"""BeatGrid — anchor + tick math for the metronome LED scheduler.""" + +import pytest + +from modalapi.ws_protocol import BEAT_SYNC_NEW_BAR, BEAT_SYNC_TEMPO_CHANGED, BeatSyncMessage +from pistomp.beatsync import FLASH_US, STALE_AFTER_US, BeatGrid, TickState + + +def _anchor(t_us=0, bpm=120.0, bpb=4.0, beat_in_bar=0.0, flags=BEAT_SYNC_NEW_BAR) -> 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 4c8487243..83eaf806c 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 @@ -564,6 +564,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 that ping-pong scrolls its contents when selected."""