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