Skip to content

[WIP] Loop Jefe - #192

Draft
sastraxi wants to merge 2 commits into
mainfrom
feat/loopjefe
Draft

[WIP] Loop Jefe#192
sastraxi wants to merge 2 commits into
mainfrom
feat/loopjefe

Conversation

@sastraxi

@sastraxi sastraxi 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.

Per-plugin footswitch LED customization

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:

Mode Condition
FILL Length is known, and the transport is anchored. Determinate.
CHASE Length is not known, or the loop is longer than it declared.
STATIC The loop exists, but it does not move.
FREE The 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

@sastraxi
sastraxi changed the base branch from main to fix/longpress-unbind-config August 24, 2026 01:46
@sastraxi sastraxi changed the title [WIP] Loop Jefe Loop Jefe Aug 24, 2026
@sastraxi sastraxi changed the title Loop Jefe [WIP] Loop Jefe Aug 29, 2026
Base automatically changed from fix/longpress-unbind-config to main August 29, 2026 19:04
Sign up for free to 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