Skip to content

Loop Jefe - #192

Draft
sastraxi wants to merge 32 commits into
fix/longpress-unbind-configfrom
feat/loopjefe
Draft

Loop Jefe#192
sastraxi wants to merge 32 commits into
fix/longpress-unbind-configfrom
feat/loopjefe

Conversation

@sastraxi

@sastraxisastraxi commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

Live looping on pi-Stomp. The device now shows a multitrack looper UI for loopjefe instances per footswitch binding. Each has a progress border that visualizes loop progression and a beat pulse that comes from the transport clock.

No looper-specific code is in the handler, the LCD, or the input path.

  1. Momentary ports. A footswitch bound to an pprops:trigger port sends one rising edge, and does not toggle.
  2. Declarative LED behavior. A plugin gives an LedSpec. The generic driver reads the plugin output ports and makes the color, the style, and the loop position.
  3. A beat grid. A clock sample from the host anchors a grid. The device extrapolates the position between samples. This requires Beat sync mod-host#3
  4. Output-port mirroring. The WebSocket worker keeps only the output_set frames that a loaded plugin asked for, and drops the rest.

Before you start

Two things are external to this repository:

  • The beat_sync message comes from our mod-ui fork. Stock mod-ui does not send it. Without it, the device falls back to the tap tempo (a free grid).
  • The plugin is https://github.com/sastraxi/loopjefe-lv2.

A route through the code

Let's start at BeatSyncMessage, which allows us to synchronize our visuals with the audio "beat". OutputSetMessage is required for us to observe changes from the loopjefe plugins (and other plugins, if we care to listen).

BeatGrid holds one anchor and extrapolates from it:

pos(t) = beat_in_bar + (t - t_us) * bpm / 60

Each new sample fully replaces the anchor. A late sample or a lost sample means more extrapolation, so in practice de-syncs should be rare. The downbeat is computed locally.

tick() produces a TickState. When there is no anchor, or when the anchor is more than 5 seconds old, tick() falls through to _free_tick() and uses the tap tempo. A free grid has beats but no bar, so it has no downbeat and no loop position.

The one subtle part is _ANCHOR_CROSSING_EPSILON_BEATS in on_anchor(). If we recieve. The code seeds the beat index one behind, or the first tick() never sees the crossing.

LedSpec is the the plugin-facing contract for how it wants to drive its LED if it's bound to a footswitch:

  • state_symbol — the output port whose integer value selects a color.
  • downbeat_symbol — an output port that is 0 on the loop's own downbeat.
  • bars_symbol — the loop length in bars. It is the denominator for the position. 0 means "not known yet".
  • chase_states — states with a position but no denominator, such as a first take that is still recording.
  • labels — the port is an lv2:OutputPort, so its scalePoints never reach us as a Parameter. The names must be declared here.

The renderer (modalapi/led_render.py)

Pure function of (LedSpec, output_values) -> LoopProgress. The four LoopFill modes are the model of the feature:

ModeCondition
FILLLength is known, and the transport is anchored. Determinate.
CHASELength is not known, or the loop is longer than it declared.
STATICThe loop exists, but it does not move.
FREEThe loop runs, but the free grid gives no position.

Handler changes

  • :1072BeatSyncMessage anchors the grid. OutputSetMessage at :1075 writes to plugin.output_values.
  • :666_drive_footswitch_leds() — called each 10 ms tick from poll_controls. It ticks the grid one time and applies the result to every footswitch, so all LEDs pulse together.
  • :718_render_footswitch() and :761_write_led() — where the color reaches the hardware. pistomp/footswitch.py:set_led is now pure state, and it makes no hardware write. This side-steps a conflict between the LED driver and the pixel driver.
  • :1415_update_interesting_outputs() — makes the subscription set from plugin.monitored_output_symbols and gives it to the bridge.
  • :538 and :566 — the momentary path. If the bound Parameter is a trigger port, the press calls Parameter.pulse() (common/parameter.py) instead of a toggle.

Websocket filter

Before this branch, output_set was dropped whole, because the audio meters flood it. Now the worker keeps the frames whose instance/symbol key is in _interesting.

Note that _latest_outputs holds the last unsubscribed value per port. mod-ui dumps every monitored port at connect time, before the board binds. Without the replay, the first paint is stale until the plugin next moves.

Painting

  • uilib/glyphs/perimeter_progress.py — draws an arc on the perimeter of a rounded rectangle. segments puts bar ticks around it.
  • uilib/glyphs/loop_icon.py — the racetrack icon that replaces the plugin name when loop_icon=True.

Unrelated fixes

  • Property parsing (common/parameter.py). MOD-UI gives short property names in some paths and full LV2 URIs in others. _has_property() accepts both. Before this, a plugin that reported full URIs got no type at all.
  • ContextLayer.remove() (common/contexts.py) — the mutation counterpart to add(), so callers do not touch rows directly.

TODO

  • MODGUI
  • layer count + current
  • dim reset / undo / redo when not possible
  • better visual design of footswitch borders
  • consistent visual design language between MOD-UI and pi-Stomp for plugin viz

sastraxiand others added 29 commits July 3, 2026 20:42
mod-host/mod-ui now broadcast an absolute-timestamped downbeat over
WebSocket (beat_sync); pi-Stomp parses it, tracks a BeatGrid anchored
to CLOCK_MONOTONIC, and flashes the tap-tempo footswitch's LED/pixel
on each beat as a visual metronome (no audible click is available on
v3's single shared DAC).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… group
Extends the 'longpress' config field to accept {midi_CC: N} alongside the
existing named-group form (string or list), so a footswitch's longpress can
MIDI-learn directly onto a plugin port (e.g. loopjefe's future "reset")
without inventing a new pi-Stomp-internal callback for every such action.
Kept as one field rather than a sibling 'longpress_midi_CC' key so a
per-pedalboard overlay can't leave a stale chord-group registration behind
when a later config only overrides one of the two.
Consolidates the near-duplicate _emit_midi (mod.py/modhandler.py) into a
single implementation on the Handler base, since both were already routing
through self.hardware — no new shared state needed, just an optional CC
override so longpress can target a different port than the short-press
binding. Also adds 'toggle_tuner_enable' to the longpress schema enum: it
was already a registered callback and used in both shipped templates, but
the old loose ["array","string"] schema silently skipped enum validation
for the bare-string form.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts:
#	modalapi/websocket_bridge.py
Brings the branch up to v3.3.0 (AGPL relicense, footswitch UI, v1 removal).
Longpress-sends-MIDI was implemented independently on both sides; main's is
the superset (LongpressActionConfig -> RawMidiCcEffect, plus preset/pedalboard
targets), so the branch's narrower Footswitch.set_longpress/longpress_midi_CC
layer and its tests are dropped in favor of it. Likewise Handler.
_handle_footswitch, superseded by main's Effects/BindingDecl dispatch.
Kept from the branch: the per-tick LED driver (set_led is a pure state update,
_drive_footswitch_leds renders), LedSpec/render_led_spec, generic output_set
mirroring into Plugin.output_values, BeatGrid, and Parameter Type.TRIGGER /
is_momentary.
v1 (modalapi/mod.py) is gone with main's "Remove v1", taking the branch's v1
metronome work with it.
Momentary trigger semantics are NOT yet wired into main's Effect model --
MidiCcEffect and RawMidiCcEffect both still toggle, so a pprops:trigger port
(loopjefe advance/reset) only fires every other press. Follow-up.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	modalapi/modhandler.py
#	pistomp/handler.py
#	pistomp/hardware.py
#	tests/test_hardware.py
#	tests/v3/test_hardware_config.py
@sastraxi
sastraxi changed the base branch from main to fix/longpress-unbind-configAugust 24, 2026 01:46
@sastraxisastraxi changed the title [WIP] Loop JefeLoop JefeAug 24, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@sastraxi