From f1c1b4c9924dad327f5dc111e71d6444827ec2e0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:42:32 -0400 Subject: [PATCH 1/8] loopjefe plan --- docs/multitrack-looper-plan.md | 838 +++++++++++++++++++++++++++++++++ 1 file changed, 838 insertions(+) create mode 100644 docs/multitrack-looper-plan.md diff --git a/docs/multitrack-looper-plan.md b/docs/multitrack-looper-plan.md new file mode 100644 index 000000000..b23215aeb --- /dev/null +++ b/docs/multitrack-looper-plan.md @@ -0,0 +1,838 @@ +# Multitrack Looper Plan (LoopJefe LV2 + pi-Stomp) + +Implementation fork lives at `~/dev/loopjefe-lv2` (renamed from a clone +of `mod-audio/sooperlooper-lv2-plugin`). The original working title +"multitrack looper" survives in the doc name; the canonical scope +is **three freely-recorded loop tracks of different lengths, locked +to a shared beat grid, controlled from footswitches alone**. + +## Goal + +Give pi-Stomp genuine RC-505-style live looping: several independently +recorded loop tracks of *different*, freely-played lengths (a 4-bar +chord loop, a 16-bar bassline recorded right after it, in either order) +that always land on a shared beat grid — using footswitches alone, and +optionally a richer external MIDI controller — without pi-Stomp becoming +an audio router or instrument coordinator. The reference point +throughout is Marc Rebillet's live workflow (Boss RC-505 + keyboard): +build a song from nothing, live, by layering loops of different lengths +that never drift apart, with nothing pre-declared about how long any +given loop "should" be. + +## Why LV2/mod-host, not the standalone SooperLooper engine + +We evaluated and rejected running the full `essej/sooperlooper` JACK +app (OSC-controlled, natively multitrack within one process), because: + +- It sits **outside** mod-host's pedalboard, so it can't be freely + positioned in the signal chain — no per-track pre/post-loop effects, + which is a hard requirement (users need to choose what processes a + track's input before it's recorded, and what processes its output + after). +- It would require pi-Stomp to own audio routing (JACK port patching) + and, if we want a MIDI-bindable control surface for digital + instruments, instrument/MIDI coordination too — both explicitly out + of scope. pi-Stomp is a control/monitoring layer over mod-host, not + the thing coordinating real audio (the one deliberate exception is + **blend mode**). + +Instead: **three separate instances of the SooperLooper LV2 plugin**, +one per track, placed anywhere in a mod-host pedalboard like any other +plugin, each independently addressable via mod-ui's existing MIDI-learn +system. + +## Researching the reference device: RC-505 (mkI) mode matrix + +Before designing our own behavior we read the actual RC-505 mkI +owner's manual end to end. Four largely independent per-track settings +govern length/sync behavior: + +| Axis | Options | What it controls | +|---|---|---| +| `Loop Sync` | ON / OFF | ON: playback start phase-aligns to the group's shared downbeat. OFF: loops independently, retriggers from its own bar 1. | +| `Measure` (only meaningful if `Loop Sync=ON`) | fixed number (1, 2, 4, 8...) / `AUTO` / `FREE` | Fixed: you type in an exact bar count ahead of time, recording is forced to fit it. `AUTO`: copies whatever the *first-recorded* AUTO track ended up being. `FREE`: *"set automatically, corresponding to the length of the recording"* — whatever you actually played, measured after the fact, nothing pre-declared. | +| `Quantize` (only active if something's already playing/synced to quantize against — rhythm on, an existing synced track, or MIDI sync) | `REC END` / `MEASURE` / `BEAT` | Governs *timing precision at the edges* — snaps record-start (always) and, depending on mode, overdub/playback boundaries to either a measure or a beat. | +| `Tempo Sync` | ON / OFF | ON: track plays at the shared phrase-memory tempo (may stretch). OFF: always plays at its own "original tempo," never stretched. | + +Watching Marc's livestreams (a 4-bar loop, then a 16-bar loop recorded +immediately after, in that order, nothing hesitant or dialed-in +beforehand) is consistent with `Loop Sync=ON` + `Measure=FREE` + +`Quantize=BEAT` + `Tempo Sync=ON` — **not** a fixed/typed-in bar count +on either track. Nothing was pre-declared; both lengths were simply +measured from what he played, snapped to the beat grid, and it worked +because a shared tempo already existed underneath both recordings. + +**Decision: hardcode exactly this combination.** Every track +permanently behaves as `Loop Sync=ON, Measure=FREE, Quantize=BEAT, +Tempo Sync=ON`. This is a deliberately narrow, opinionated first version +matching the one workflow we're targeting, not a general-purpose +emulator. + +## Architecture: how freely-recorded loops of different lengths stay locked (Design A) + +mod-host already implements the LV2 `time:` extension in full +(`mod-host/src/effects.c`). Two facts, confirmed by reading the source, +make this simpler than first assumed: + +- **mod-host is the JACK timebase master** (`jack_set_timebase_callback`, + `JackTimebase` — search the file). It always emits valid BBT + (`pos->valid = JackPositionBBT`), so `bar`, `barBeat` (fractional + 0-indexed beat-within-bar), `beatsPerBar`, `beatUnit`, and + `ticksPerBeat` are authoritative — not just `beatsPerMinute`. +- **While the transport is *rolling*, `pos.frame` advances every audio + cycle, so the change-guard at `effects.c` (the forge site) fires + every `run()`** — mod-host re-forges and pushes a fresh + `time:Position` (`speed`, `frame`, `bar`, `barBeat`, `beatsPerBar`, + `beatsPerMinute`, `ticksPerBeat`) into every subscribed instance + **every block**. It only falls silent when the transport is *stopped* + (frame frozen). + +It reaches any plugin that declares an atom input port supporting it +(`lilv_port_supports_event(..., timePosition)` sets `HINT_TRANSPORT` on +that port). Critically, **this is a broadcast, not a wire**: pushed +automatically into every subscribed instance with zero manual patching +in mod-ui. Real and working today; nothing about it needs to change. + +Given that shared clock (tempo + absolute phase) is established once, +*before* anyone hits record, each of the three LoopJefe instances +implements `FREE`-mode behavior **entirely locally**, with no +instance ever needing to know what another instance did. + +**Quantization is to the measure (bar), not the beat.** This is the +load-bearing behavioral decision. For loops of *different* lengths to +stay musically locked, each loop's length must be an integer number of +*bars* and each must start on a downbeat — then a 16-bar loop is exactly +4× a 4-bar loop's cycle and their downbeats always coincide. Beat-only +quantize would permit a "4 bars + 1 beat" (17-beat) loop that never +lines up with a 16-beat loop's bars, defeating the whole multitrack-lock +goal. Measure alignment also matches the RC-505 faithfully: its record +quantize is `REC END` = "quantize to the measure start location" (mkI +manual p.15) and its count-in is a full **1-measure** count (p.19). +`beatsPerBar` is in the atom and persisted per-pedalboard, so this costs +nothing to read. + +1. Each `run()` while rolling, read the latest `time:Position` from the + `time_info` atom port: cache `frame`, `bar`, `barBeat`, `beatsPerBar`, + `beatsPerMinute`, `speed`. Because mod-host hands us absolute phase + every block, **we never integrate our own frame counter** — no drift. + Derive `beat_length_samples = sample_rate * 60 / bpm` and + `bar_length_samples = beat_length_samples * beatsPerBar` for boundary + math. +2. On `record` press, snap the actual start to the **next bar boundary**, + sample-accurate *within the block* by computing the sample offset to + the next downbeat from the atom's `frame`/`bar`/`barBeat`. The window + between press and that boundary is the count-in (up to a full bar). +3. Record for however long the footswitch is held — genuinely free, + nothing pre-declared, nothing capped. +4. On `record` release, snap the stop so the total recorded length is the + **nearest whole number of bars** (round to closest, not always up or + down), computed from `bar_length_samples`. The resulting loop length + is therefore always an exact whole number of bars, without ever having + been told in advance how many. + +Edge cases the implementer must handle: a release *before* the first +bar boundary has even been reached (recording never really started — +abort cleanly, leave the track empty); and rounding a sub-one-bar take +to a minimum of one bar rather than zero. + +**Precondition — the transport must be rolling.** Beat-sync only works +while mod-host's transport is rolling (frame advancing); when stopped, +`frame`/`barBeat` are frozen and no boundaries can be found. Transport +rolling *and* BPM are **persisted per-pedalboard** (`timeInfo.rolling`, +`timeInfo.bpm`, applied at load), so the "shared clock exists before +anyone records" premise is satisfied by shipping the looper pedalboard +with `rolling: true` and a sensible default BPM. **The plugin must +fall back gracefully** when the transport is stopped or `speed == 0`: +behave as a free-running (unquantized) record rather than never +triggering. + +**pi-Stomp does NOT own the transport.** Deliberate design choice: an +external MIDI controller (or a keyboardist's device) typically owns +play/stop, and we don't want to fight it. pi-Stomp is a pure transport +*slave* — it reads the beat grid (see "Beat-grid sync" below) and never +calls start/stop. Starting the transport is left to: the pedalboard's +persisted `rolling: true`, an external controller's transport button, or +a footswitch the *user* chooses to MIDI-learn onto the `:rolling` +global port. That last option is available but not required and not +something pi-Stomp drives on its own. + +**This is why no recording-order constraint exists.** Any of the three +tracks can be recorded first, second, or third — each one independently +locks to the same absolute beat grid the moment it's recorded, because +the grid was established by the shared broadcast tempo, not derived +from whichever track happened to go first. This directly matches what +we saw Marc do: 4 bars then 16 bars, either could have gone first, both +land cleanly on the grid. + +## Forking to `loopjefe-lv2`: identity changes + +Because this changes real-time *behavior* under an existing LV2 URI +(LV2 URIs are meant to be permanent, stable contracts), we're not +patching `mod-audio/sooperlooper-lv2-plugin` in place. The repo has +been cloned and renamed to `loopjefe-lv2` at `~/dev/loopjefe-lv2`, git +remote `github.com:sastraxi/loopjefe-lv2` (the canonical build source +for now; to be moved to a TreeFallSound org repo later — the debpkg's +`config.sh` ref will need updating at that point). Renaming the repo +alone isn't enough — every place the old plugin's identity is baked in +needs to change too, or the built `.lv2` bundle will still silently +collide with (or shadow) the original `sooperlooper.lv2` on-device. + +| Location | Current value | What needs to happen | +|---|---|---| +| `loopjefe/src/loopjefe.cpp:43` | `#define PLUGIN_URI "http://treefallsound.com/plugins/loopjefe"` | **Decided:** new URI namespace is `treefallsound.com/plugins`. Mono → `http://treefallsound.com/plugins/loopjefe`, stereo → `http://treefallsound.com/plugins/loopjefe-2x2`. This is the one change that actually matters for LV2 identity/compatibility; everything else below is bookkeeping around it. | +| `loopjefe/src/manifest.ttl` | Subject, `lv2:binary`, `rdfs:seeAlso` | Subject/binary/seeAlso filenames updated to match the renamed `.so`/`.ttl` (see directory renames below). | +| `loopjefe/src/loopjefe.ttl` | Plugin URI subject, `doap:name "LoopJefe"`, `mod:brand "MOD"` | `mod:brand "MOD"` dropped or changed to `"TreeFallSound"` — this isn't a MOD Devices-maintained plugin, keeping their brand label on it is misleading. | +| Directory names `loopjefe/`, `loopjefe-2x2/` | — | (Renamed from `sooperlooper/`, `sooperlooper-2x2/`.) The `Makefile` derives the plugin/bundle/binary name from `basename $(pwd)`, so the bundle name (`loopjefe.lv2`/`loopjefe-2x2.lv2`) cascades automatically. | +| Filenames `loopjefe.cpp`, `loopjefe.ttl` | — | (Renamed from `sooperlooper.cpp`, `sooperlooper.ttl`.) Not load-bearing (Makefile globs `src/*.cpp`), but renamed for clarity/consistency. | +| GPL copyright header (`loopjefe.cpp:1-27`) | `Copyright (C) 2002 Jesse Chappell` | **Preserved, not stripped** — this is GPL-licensed code and we're distributing a modified version. Fork/modification notice added alongside the original copyright; not replaced. `COPYING` (the GPL license file) stays as-is. | +| `modgui.ttl` (shipped separately, on-device only, not in this repo) | Points at the old `sooperlooper` URI | Needs its own new file pointing at the new URI, or the pretty pedal skin won't attach to the new plugin at all (it'll just fall back to mod-ui's generic control list — functionally fine, not blocking for v1). | +| `README.md` | — | States plainly: fork of `mod-audio/sooperlooper-lv2-plugin`, itself derived from Jesse Chappell's original SooperLooper (GPL), modified by TreeFallSound/pi-Stomp to add beat-synced multitrack recording. **Shipped.** | +| `lv2:minorVersion`/`lv2:microVersion` (`loopjefe.ttl`) | `0` / `1` | Reset from upstream's `0`/`9` — a version lineage that's ours, not a continuation of upstream's numbering. | + +The `-2x2` (stereo) variant gets the identical treatment, +independently (it's a fully separate `PLUGIN_URI`/directory/bundle, not +derived from the mono one) — same namespace, `-2x2` suffix as shown +above. + +## Plugin ports and the cycle/reset design + +The plugin is the LADSPA→LV2 SooperLooper shim with three new things +added in `loopjefe-lv2`: + +| Port | Type | Purpose | +|---|---|---| +| `time_info` | `atom:AtomPort`, `atom:Sequence`, `atom:supports time:Position` | Receives mod-host's per-block transport broadcast (frame, bar, barBeat, bpm, speed) — no pi-Stomp involvement, no mod-ui wiring. | +| `state` | `lv2:ControlPort`, `lv2:integer`, `lv2:enumeration` (5 scalePoints: Empty, Recording, Overdub, Playback, Stopped) | **Replaces** the old `play_pause`+`record` pair. Writes from any source (footswitch CC, plugin-internal, mod-ui REST) cycle the state machine; the plugin echoes its current state back via `param_set`, which pi-Stomp mirrors to the bound footswitch's LED. See "State machine" below. | +| `reset` | `lv2:ControlPort`, `lv2:integer` | Momentary trigger: when set non-zero, the plugin returns to `Empty` (state 0) and writes 0 back. Bound to the footswitch's longpress CC. | + +`time_info` is the plugin's first atom/URID code. The shim's +`instantiate()` previously ignored its `features` argument; adding +`time_info` also means: request and store `LV2_URID_Map` from `features` +in `instantiate`; map the `time:Position`, `time:frame`, `time:barBeat`, +`time:beatsPerMinute`, `time:speed` URIDs; add the `connect_port` case +for the new port; and walk the atom sequence each `run()` (manual +`LV2_ATOM_SEQUENCE_FOREACH` / object-property iteration — no forge +needed on the read side). Reference implementations to mirror: the +LV2 book's `eg-metro` example (canonical minimal `time:Position` +consumer) and any DPF/MOD tempo-synced plugin on-device. + +### State machine (plugin-owned, 5 states) + +The plugin's internal `SooperLooper::state` is a per-sample state +machine inherited from the LADSPA original. With the `state` port +exposed, the cycle a single footswitch drives is: + +| From state | Press | To state | Notes | +|---|---|---|---| +| Empty | press | Recording | Start quantizes to next downbeat (see Architecture) | +| Recording | press | Overdub | Length snaps to nearest whole bar | +| Overdub | press | Playback | | +| Playback | press | Stopped | Content retained, not playing | +| Stopped | press | Overdub | Re-enter overdub from the same content | + +The key design decision: **the plugin, not pi-Stomp, owns this state +machine.** The footswitch is dumb — it sends a single CC on every +press. The plugin reads `state` from the port, sees that the value +changed externally (CC wrote a non-zero value, or just *any* value +different from what the plugin itself last wrote), advances the state, +and writes the new value back. mod-host echoes the write as +`param_set /graph/loopjefe_1 state N`, mod-ui relays it over WS, and +pi-Stomp's existing `plugin.set_param_value("state", N)` calls +`set_value(N)` on the bound footswitch — which the footswitch uses +to color its LED (Empty=unlit, Recording=red, Overdub=yellow, +Playback=green, Stopped=dim green). + +This is the load-bearing simplification: pi-Stomp has zero per-track +state-machine code. The state lives in one place (the plugin), the +echo carries it to the LED, and the footswitch's existing +`param_set → set_value` chain does the work without any new +plumbing. Same path works for any external MIDI source (keyboard pads, +external foot controllers) bound to the `state` port in mod-ui. + +**The `state` port's value-echoing contract** (the reason this design +works): mod-host emits `param_set /graph/ ` +over its feedback socket for *any* control port value change, whether +the change came from a MIDI-learned CC, a mod-ui REST/WS command, or +the plugin writing to it in `run()`. mod-ui relays verbatim. pi-Stomp's +existing `ParamSetMessage` handler in `modalapi/plugin.py:127` calls +`set_value(value)` on any bound controller. Confirmed: this chain +is fully generic, not `:bypass`-specific. + +### Trigger vs. echo on the same port + +The `state` port serves as both the cycle trigger (the footswitch CC +writes 127 to it) and the state publisher (the plugin writes the +new state back). The plugin distinguishes external from internal +writes by tracking the value it last wrote: a value read in `run()` +that differs from the last self-written value is an external trigger; +the plugin cycles and rewrites. A matching value means no trigger +happened this block. + +This is the same pattern as a tap-tempo button: the port goes 0 → +momentary value → 0, and the plugin reacts to the momentary value. + +### `reset` (longpress → clear) + +`reset` is a separate, simple integer port. The footswitch's longpress +fires a *second* CC (different from the short-press CC). Both are +MIDI-learned in mod-ui: short-press CC → `state`, longpress CC → +`reset`. Pressing long: CC=127 sent, mod-host writes 127 to `reset`, +plugin resets to `Empty` (state 0) and writes 0 back. The `state` +echo lights the footswitch to off. The `reset` echo is ignored (its +value has no meaning beyond the trigger). + +This requires a small pi-Stomp extension: the existing `longpress` +config in `pistomp/config.py` is a closed enum of pi-Stomp-internal +behaviors (`next_snapshot`, `previous_snapshot`, `toggle_bypass`, +`set_mod_tap_tempo`, `toggle_tap_tempo_enable`). It needs to grow +arbitrary "send this CC" actions, the same way short-press bindings +already target arbitrary CCs/parameters. **Deferred to a follow-up +PR** — the plugin can land first and be exercised with two separate +footswitches (one bound to `state`, one to `reset`) for testing. + +### What stays out of v1 + +- `cycle_beats`, `quantize`, `measure_count`, `sync_target` ports — + FREE+BEAT-quantize-to-the-bar is hardcoded, not configurable. +- Reverse, one-shot, multiply, insert, replace, + configurable-Loop-Sync/Measure/Quantize/Tempo-Sync modes. +- `modgui` skin update for the new state-machine UX (the old skin + still works against the new plugin; the new URI just needs the + modgui file repointed at it, which is on-device-only and not + blocking). + +## What gets MIDI-learned through MOD-UI + +Every control port on an LV2 plugin instance is automatically +learnable in mod-ui — bind any incoming MIDI CC or note to any port, +per instance, per pedalboard, entirely inside mod-ui's existing UI. +This was true before our changes and doesn't need new pi-Stomp code +to work. The live-performance bindings per track are: + +- `state` — single CC drives the entire cycle. Bound to a pi-Stomp + footswitch's short-press CC, or to a keyboard pad, or to anything + else speaking MIDI into mod-host. +- `reset` — separate CC clears the track. Bound to a pi-Stomp + footswitch's longpress CC, or to a dedicated pad. +- `dryLevel` — a natural target for an expression pedal or a + controller knob (blend-mode-adjacent). + +Two independent sources can feed these learned bindings, and they can +coexist: + +1. **pi-Stomp footswitches**, which already emit raw MIDI CC + (alternating 0/127 for toggle-style presses). No plugin awareness + needed on pi-Stomp's side; it just sends CC, and mod-ui's learned + binding routes it to the right port. +2. **Any external MIDI device** — a keyboard's own pads/buttons, a + dedicated MIDI foot controller, anything speaking MIDI into + mod-host — bound the exact same way, entirely inside mod-ui, with + zero pi-Stomp involvement. A digital instrument's own loop-control + pads can be bound directly to the same `state` port pi-Stomp's + footswitches use, and multiple sources can be bound to the same + port simultaneously (e.g. both a footswitch and a keyboard pad + trigger `state` on track 2). + +## Live UX with pi-Stomp's 4 footswitches (3 tracks + metronome) + +Three tracks, one footswitch per track. The 4th switch is the +global metronome, reusing pi-Stomp's existing tap-tempo switch. This +discards the earlier "3 tracks + 1 modifier switch" design (with +chorded FSmod + FSn for clear and global-stop): the plugin's +5-state cycle makes a modifier switch unnecessary, and a single +footswitch per track is a much simpler learn. + +| Switch | Gesture | Bound to | Action | +|---|---|---|---| +| FS1/2/3 | short press | `state` (per track) | Cycle Empty → Recording → Overdub → Playback → Stopped → Overdub → … | +| FS1/2/3 | long press | `reset` (per track) | Clear the track back to Empty | +| FS4 | (n/a; metronome is a global visual) | — | Per-beat flash driven by the beat-grid sync (see below) | + +Two of the three real gesture tiers pi-Stomp's footswitches already +support are used: + +1. **Single (short) press** — release before 500ms. Fires immediately, + zero added latency. This is the tier every latency-sensitive, + frequently-hit action lives on. +2. **Long press** — held ≥500ms. Fires a `longpress_callback`. Today + the *set* of valid long-press actions is a fixed enum + (`pistomp/config.py`); to support the looper's longpress-as-clear, + this needs to grow arbitrary CC/parameter targets. **Deferred to a + follow-up PR.** +3. **Chord** — long-press-based simultaneity. Not used by the looper + (the modifier-switch design was removed). The chord machinery + stays for non-looper features (`next_snapshot`, `previous_snapshot`, + `toggle_bypass`, `toggle_tap_tempo_enable`). + +## Beat-grid sync: crossing the RT barrier (transport slave) + +pi-Stomp drives a synced metronome LED from the same beat grid the +plugin uses for its bar-quantize — with no audible click available +(the v3 codec has one shared stereo DAC; the "headphone" and "line" +jacks carry the *same* signal, so we cannot route a click to +phones-only like an RC-505; `mod-ui`/`mod-host` have no built-in +metronome). **The LED is the only feedback, so timing accuracy +matters and drift is the enemy.** + +### The clock-domain problem (and JACK's answer) + +The Pi's CPU clock and the codec's audio-sample clock are **different +crystals** and drift against each other. JACK's frame counter runs on +the *audio* clock; pi-Stomp schedules on the *system* clock. The bridge +is a real JACK primitive: **`jack_frames_to_time(client, frame)` → +`jack_time_t` (µs)**, backed by JACK's delay-locked loop that +continuously maps audio-frames ↔ system-microseconds. mod-host, as +timebase master, knows *which frame* is a downbeat (from its BBT: +`pos.bar`/`pos.beat`/`pos.frame`/`frame_rate`); JACK knows *what +system-time* that frame maps to. Neither pi-Stomp nor mod-ui has to +track consumption timing. + +### Design: mod-host emits absolute-timestamped downbeats over WS + +A pi-Stomp-side JACK client was rejected: it would drag the main +loop toward RT scheduling and the Python `jack` binding's +`transport_query()` **crashes** on-device — `unique_1 != unique_2` +ABI mismatch then a `munmap` abort, verified 20/20; the binding's +cdef also lacks `jack_frames_to_time`/`get_time`/`get_latency_range`, +so the frame→time conversion *must* happen in C anyway. Touches three +vendored repos, each additively: + +1. **mod-host (C, RT-safe):** each process cycle, from the `pos` it + already computes, detect a **downbeat** (bar-1) boundary crossing. + Convert that boundary frame to an absolute system-time and push + `{bar, t_us, bpm, beatsPerBar}` through the **existing + `rtsafe_memory_pool` postponed-event queue** (the same RT→non-RT + pipe used for `param_set` feedback — no new RT machinery). Emit + **on the downbeat only** (not every beat): one anchor + BPM lets the + consumer extrapolate the whole grid, so worst-case re-anchor + interval is one bar. The timestamp is in **`CLOCK_MONOTONIC`** in + microseconds, back-dated to the actual downbeat frame (not "now") + so consumer phase is immune to feedback/relay/WS-drain latency. +2. **mod-ui (Python):** relay as a new WS line `beat_sync + ` on the socket pi-Stomp already drains. +3. **pi-Stomp (Python):** parse (small `ws_protocol.py` add). On + receipt, `t_us` is the exact monotonic time the downbeat occurred. + Anchor the grid; drive the LED against + `time.clock_gettime(CLOCK_MONOTONIC)`. **No JACK client, no + subprocess, no RT** on pi-Stomp. + +**Why the absolute timestamp is the whole trick:** a "downbeat happened +NOW" message would be smeared by WS + relay + the 10ms drain. An +absolute `t_us` is immune — however late pi-Stomp reads it, +`phase = now − t_us` recovers the true elapsed time. Transport jitter +only delays when pi-Stomp *first learns* the grid, never the phase it +computes. + +### Audio-interface latency (answering "does frames_to_time handle it?") + +**No.** `jack_frames_to_time` maps a frame to system-time at JACK's +engine/driver boundary (anchored to the ALSA hardware IRQ). It does +**not** add the playback-port buffering + codec latency between that +boundary and the physical output jack. Here that residual is +period×nperiods = 64×2 = 128 frames ≈ **2.67 ms**, plus a few frames +of DA7212 codec latency ≈ **~4–5 ms total**. That is far below the +~20–40 ms audio-visual sync threshold the eye can detect, so the +correction is **optional, not critical** — the loops themselves are +the only audible reference and 5 ms against them is imperceptible. If +we want exactness anyway it's cheap: add +`jack_port_get_latency_range(out, JackPlayback)` (µs) to `t_us` in +mod-host. **Drift, not this constant offset, is the thing that +actually matters** — and the per-downbeat re-anchor bounds drift to at +most one bar. + +## LED feedback + +### Metronome (FS4, delivered) + +FS4 (the tap-tempo switch) is the global visual metronome. It's +driven by the beat-grid anchor: a short **~80 ms flash per beat** +(≈8 main-loop ticks at 100 Hz), with the downbeat flashed brighter. +Concretely, the LED strip pixel and the GPIO LED on FS4 are both +flashed for 80ms after each beat boundary crossing, with the strip +color at full white (255, 255, 255) on a downbeat and a slightly +dimmer white (180, 180, 180) on regular beats — the two different +RGB values render as visually distinct whites against the strip's +global `LED_BRIGHTNESS = 0.19`. The GPIO LED has no per-channel +brightness, so it just goes on for both. + +When the transport stops, mod-ui sends `transport 0 …`, which pi-Stomp +uses to clear the grid immediately. The 5-second stale timeout in +`BeatGrid` is a safety net for a crashed mod-host that produces no +events at all. With no anchor, the metronome driver leaves the +pixels off; the taptempo's own set_led path (or the next category +update) restores FS4's normal state. + +### Per-track state color (FS1/2/3, deferred to plugin-side PR) + +Each track's LED should mirror the plugin's `state` value, per the +RC-505 track-button convention: + +| LED | State | +|---|---| +| Unlit | Empty (no phrase) | +| Red | Recording | +| Yellow | Overdubbing | +| Green | Playing | +| Dim green | Stopped-but-has-content | + +The LED color is **derived from the enumeration's scalePoint labels**, +not from a hardcoded value→color table. The plugin's TTL declares the +`state` port with `lv2:enumeration` and 5 scalePoints whose `rdfs:label` +is one of the conventional names: + +``` +:state a lv2:InputPort, lv2:ControlPort ; + lv2:integer ; + lv2:enumeration ; + lv2:scalePoint [ rdfs:label "Empty" ; ] ; + lv2:scalePoint [ rdfs:label "Recording" ; ] ; + lv2:scalePoint [ rdfs:label "Overdub" ; ] ; + lv2:scalePoint [ rdfs:label "Playback" ; ] ; + lv2:scalePoint [ rdfs:label "Stopped" ; ] . +``` + +The label→color mapping lives in the **plugin customization** system +(`modalapi/plugin_customization.py` + `plugins/customization.py`), +which is the right home for "this plugin's bound parameters have a +non-trivial value→LED-color mapping." A new optional field on +`PluginCustomization`: + +```python +# modalapi/plugin_customization.py +LedColorFn = Callable[[Footswitch, float], tuple[int, int, int] | None] + +@dataclass(frozen=True) +class PluginCustomization: + panel_cls: type[PluginPanel] | None = None + display_name: str | None = None + display_name_fn: Callable[[Plugin], str | None] | None = ... + subtitle_fn: Callable[[Plugin], str | None] | None = ... + intercept_shortpress: bool = False + tile_active_color: tuple[int, int, int] | None = None + tile_border: RectBorder | None = None + extra_data: PluginExtraData | None = None + footswitch_led_color_fn: LedColorFn | None = None # NEW +``` + +The function is called from `Footswitch.set_led` whenever a +`footswitch_led_color_fn` is set on the bound plugin's customization; +it returns an `(r, g, b)` triple for "lit in this color" or `None` +for "unlit." If the customization isn't set, `set_led` falls through +to the existing category-color logic (`Pixel.set_color_by_category` ++ on/off), so all existing plugins (and `:bypass`-bound footswitches) +behave exactly as today. + +The loopjefe customization is then a small package under +`pi-stomp/plugins/loopjefe/`: + +```python +# pi-stomp/plugins/loopjefe/__init__.py +from modalapi.plugin import Plugin +from modalapi.plugin_customization import PluginCustomization +from plugins.customization import register +from pistomp.footswitch import Footswitch + +LOOPJEFE_URIS = ( + "http://treefallsound.com/plugins/loopjefe", + "http://treefallsound.com/plugins/loopjefe-2x2", +) + +_LABEL_TO_COLOR = { + "Empty": None, # unlit + "Recording": (255, 0, 0), + "Overdub": (255, 255, 0), + "Playback": ( 0, 255, 0), + "Stopped": ( 0, 80, 0), # dim green +} + +def _state_to_color(footswitch: Footswitch, value: float) -> tuple[int, int, int] | None: + param = footswitch.parameter + if param is None or not param.enum_values: + return None + for scale_point in param.enum_values: + if float(scale_point.get("value", 0)) == value: + return _LABEL_TO_COLOR.get(scale_point.get("label", "")) + return None + +register( + *LOOPJEFE_URIS, + customization=PluginCustomization(footswitch_led_color_fn=_state_to_color), +) +``` + +The value arriving via `param_set` is converted to a color in three +steps: `value → scalePoint (via enum_values lookup) → label +(scalePoint.rdfs:label) → color (_LABEL_TO_COLOR[label])`. A +scalePoint whose label isn't in the map returns `None` (unlit) and +the footswitch falls back to its existing category color. **The +plugin author controls what the LED shows by choosing scalePoint +labels** — not by being known to pi-Stomp via a URI prefix or a +hardcoded value table. Any plugin that declares a `state`-like port +with the conventional labels will get the right colors without +further pi-Stomp changes (just register a customization). + +**Generalizes beyond `:bypass`.** The function takes +`(footswitch, value)` — it doesn't care which port. Today's +`param_set → Footswitch.set_value → Footswitch.set_led` chain runs +for any MIDI-mapped parameter; the customization just gets to +override the LED color logic. For `:bypass` (binary; the category +color is fine) the customization simply isn't set, and the existing +logic applies unchanged. For multi-state ports like loopjefe's +`state`, the customization takes over. A plugin that wanted a +gradient-color display for a continuous parameter could register a +customization that maps `(lo, value, hi)` to a color — same hook, +no new infrastructure. + +The wiring on the param_set side already exists: when a +`param_set /graph/loopjefe_1 state 2.0` arrives, `plugin.set_param_value` +in `modalapi/plugin.py:127` calls `set_value(2.0)` on the bound +footswitch controller, which calls `set_led(...)`, which consults +the customization. The deferred work is therefore small: add the +new customization field, plumb a back-reference from `Footswitch` +to the bound `Plugin` (set in `_bind_controller_to_param`), and +update `set_led` to call `footswitch_led_color_fn` before the +default path. A `param_set … state` echo with the right scalePoint +labels will already work correctly today (the existing `set_value` +ignores the value if the parameter's type is `Type.ENUMERATION`, +but the enumeration's scalePoint labels are already parsed and +available via `Parameter.get_enum_value_list()`). What is *not* +done is the customization hook in `set_led` and the loopjefe +customization itself — both small. Until the loopjefe plugin +exposes `state` (deferred), this is moot. + +### Count-in pulse (was speculative, removed) + +The earlier plan called for "pulsing red during a record-pending +window" — visual substitute for RC-505's audible 1-measure count-in +that we can't reproduce (single shared DAC, no phones-only bus, no +mod metronome). With the plugin owning the state machine and the +press-to-record gap being at most one bar (~1-2s at typical tempos), +the visual transition is now: green (Stopped) → brief black → red +(Recording). The plugin goes from Stopped to Recording on the next +downbeat, and the LED is driven by the `state` echo. The +"pulsing red during the gap" was tied to pi-Stomp's pre-state-machine +design; it's removed. + +## What changes when more MIDI is available + +Everything above still applies — footswitches are just one MIDI source +among possibly several bound to the same ports. More MIDI availability +buys: + +- **More tracks addressable without a modifier switch.** A keyboard + with enough pads can bind directly to N tracks' worth of `state` + ports, no chord addressing needed at all. +- **Real secondary actions without the 500ms+ long-press cost.** Undo, + redo, per-track dry level — each gets its own dedicated pad or + knob instead of being squeezed into chords. +- **Expression control** — a pedal or mod wheel bound to `dryLevel` + live, which is the one place pi-Stomp's "blend mode" is explicitly in + scope to help coordinate. +- Bass/keys/pads on the same device don't have to be sacrificed for + loop control — with enough MIDI surface, looping and playing can be + simultaneous and physically separate, which footswitches alone can't + offer since your feet are busy but so is everything else. + +## Plugin state-machine touchpoints (`loopjefe/src/loopjefe.cpp`) + +The engine is a per-sample state machine (`pLS->state`) inherited from +the LADSPA original. The five `STATE_*` values reachable from the new +`state` port (the rest are dead code with no path that sets +`state` or `nextState` to them): + +| State | # | Meaning | +|---|---|---| +| `STATE_OFF` | 0 | Empty (no phrase) | +| `STATE_TRIG_START` | 1 | armed, waiting to begin recording — **bar-start quantize injection point** | +| `STATE_RECORD` | 2 | actively recording | +| `STATE_PLAY` | 4 | playing the loop | +| `STATE_OVERDUB` | 5 | overdubbing | + +(`STATE_TRIG_STOP` (3) and `MULTIPLY`/`INSERT`/`REPLACE`/`DELAY`/ +`MUTE`/`SCRATCH`/`ONESHOT` (6-12) are dead code — no control path ever +sets `state` or `nextState` to any of them.) + +`STATE_OFF` here is "empty," not "stopped." The 5-state UX described +in the table above is implemented as a thin wrapper that maps the +`state` port's value to the internal `pLS->state` transitions: Empty +↔ Recording ↔ Overdub ↔ Playback ↔ Stopped ↔ Overdub. "Stopped with +content" and "Empty" are externally distinct but internally the +plugin returns to `STATE_PLAY`/loops; the "stopped" surface state is +maintained by the wrapper so the LED reflects it. + +Touchpoints: +- **`state` port write → cycle**: setting `state` to a value different + from the last plugin-written value advances the state machine and + writes the new value back. The 5-state cycle is implemented in the + wrapper; the underlying `STATE_*` transitions are unchanged from the + earlier free-form `play_pause`/`record` design. +- **Start quantize** (`STATE_TRIG_START` handler): same as before — + computes the sample offset to the next bar boundary from the cached + transport (`barBeat`, `beatsPerBar`, `beatsPerMinute`), reuses the + existing block-local state-transition idiom (advance `lSampleIndex` + to the trigger sample, push the new loop chunk, set `state = + STATE_RECORD`, then `break` so the outer `while` loop immediately + re-dispatches into the `STATE_RECORD` case for the remainder of + the block — sample-accurate, no dead time). Falls back to + triggering immediately when the transport isn't rolling. +- **Length finalize on stop**: the real "stop recording" event is the + `*(plugin->record) <= 0.0 && plugin->recording` branch in `run()`'s + control-reading section (not `STATE_TRIG_STOP`, which is + unreachable). Round-to-nearest-bar is applied there, gated on + `pLS->state == STATE_RECORD` so overdub stops (which inherit their + source loop's length verbatim) are left alone. The `state`-port + transition from Recording → Overdub happens here. +- **`reset` port write → Empty**: any non-zero value on `reset` + triggers a hard reset of the loop's audio content and + `state`-wrapper variable back to `STATE_OFF` / `Empty`. + +Both `loopjefe/` and `loopjefe-2x2/` carry a full copy of this file — +every change is applied twice (the dirs are independent bundles, not +shared code). The stereo variant's `STATE_TRIG_START` steps its outer +sample loop by `NUM_CHANNELS` while indexing input frames directly by +that same stepped index, inconsistent with `STATE_RECORD`'s +per-sample loop just below it — pre-existing, left as-is; the +bar-boundary trigger reuses the same loop shape the amplitude trigger +it replaced had. + +### How to verify (before touching pi-Stomp) + +The plugin is fully testable via mod-ui MIDI-learn, no pi-Stomp code: + +1. Build locally: `make` at repo root produces `loopjefe.lv2`/ + `loopjefe-2x2.lv2`. Install to a MOD host (MOD Desktop, or scp to + the device's `~/.lv2/`). +2. Load a pedalboard with the plugin. Set transport **rolling** with + a known BPM/`beatsPerBar` (persist `timeInfo.rolling: true` or hit + play). +3. MIDI-learn a CC (any source) to `state`. +4. **State echo:** press the footswitch. The plugin's `state` value + should advance (0 → 1 → 2 → 3 → 4 → 2 → 3 → 4 → …) and be + reflected in the mod-ui plugin UI. +5. **Start quantize:** trigger `state` at a random moment mid-bar; + confirm the Empty → Recording transition lands on the next + downbeat, not instantly. Watch the loop-length readout — first + pass may need `fprintf` debugging of the computed boundary frame. +6. **Length quantize:** record for ~3.5 bars then trigger the + Recording → Overdub transition; confirm the resulting loop length + is an integer number of bars (4), not 3.5. +7. **Multitrack lock (the real test):** on two instances, record a + 4-bar loop and a 16-bar loop in *either order*; confirm their + downbeats stay coincident over many repeats (this is the whole + point). +8. **Fallback:** stop the transport, trigger a transition; confirm + it free-runs (transitions immediately, unquantized) instead of + hanging. +9. **Reset:** trigger `reset`; confirm the track clears and the + `state` value goes back to 0. +10. Confirm the modgui still loads with the added `state` and `reset` + ports (or falls back to the generic control list without + erroring). + +## Packaging: `loopjefe-lv2` debian package (`pi-gen-pistomp`) + +Ships to devices via the existing apt-repo OTA flow, mirroring the +LV2 package pattern already used by `cabsim-lv2` / `veja-*-cab-lv2`: + +1. **`config.sh`** — add `LOOPJEFE_LV2_REPO` / `LOOPJEFE_LV2_REF` + alongside the existing `CABSIM_LV2_REPO/REF`. Repo = + `https://github.com/sastraxi/loopjefe-lv2`, ref = `main` (or a + pinned tag once cut). +2. **`debpkgs/loopjefe-lv2/`** — new package dir cloned from the + cabsim template: + - `build.sh` — clone the ref, `record_upstream_sha`, copy + `debian/` over, `dpkg-buildpackage -b -us -uc`, `move_to_cache`. + - `debian/control` — `Source: loopjefe-lv2`, `Build-Depends: + debhelper-compat (= 13), lv2-dev` (no fftw/sndfile — this + plugin has no such deps), `Architecture: arm64`. + - `debian/rules` — `override_dh_auto_build`/`install` calling the + repo's top-level `Makefile` (which recurses into both + `loopjefe/` and `loopjefe-2x2/`), installing both `.lv2` bundles + under `usr/lib/lv2/`. No modgui to copy for v1. + - `debian/changelog` — initial `loopjefe-lv2 (0.1-1) trixie` + entry. +3. **Ship it** — bump the package version via `bump-version.sh`, + push `pi-gen-pistomp#main`; `build-deb.yml` builds the `.deb` and + `publish-apt-repo.yml` updates the apt index (per CLAUDE.md's + "Shipping a new pi-stomp version" flow, same mechanism). + +Blocked only on the plugin building cleanly and the renamed/patched +tree being pushed to the build-source remote — this step comes last. + +## Open questions + +- Mid-song tempo *change* under measure-quantize: the plugin's bar + math and pi-Stomp's grid extrapolation both assume constant tempo + since the last anchor. Fine for the target workflow; revisit if + tempo automation is ever needed (JACK `bar_start_tick` handles it + authoritatively). +- Design B (explicit master→follower `AUTO`-style length mirroring) + is deferred, not rejected — worth revisiting if a workflow ever + needs one track's length forced to exactly match/multiply another's + rather than everything independently landing on the shared beat + grid. +- Plugin-side `state` port: should the value be 0-indexed (0..4) or + 1-indexed (1..5)? LV2 enumeration values are arbitrary; the + pi-Stomp LED mapping and the wrapper's internal transitions need to + agree. **Resolved for v1: 0-indexed** (Empty=0, Recording=1, + Overdub=2, Playback=3, Stopped=4) — easier to compute `bar * + bpb + beat_in_bar` style arithmetic. + +--- + +## Appendix: Delivered and remaining work + +A snapshot of the in-progress state across the three repos. Everything +listed as "delivered" is implemented in the working tree but +**uncommitted**; "deferred" is on the roadmap but not yet started. + +### `~/dev/loopjefe-lv2` (the plugin) + +| Item | Status | Notes | +|---|---|---| +| Identity rename (`sooperlooper*` → `loopjefe*`, new URI namespace) | **Delivered** | Uncommitted; staged in working tree | +| GPL copyright preservation + fork notice | **Delivered** | Header in `loopjefe.cpp` | +| `time_info` atom port + LV2_URID_Map wiring + `readTimeInfo()` | **Delivered** | Mono and `-2x2` variants | +| Bar-boundary quantize in `STATE_TRIG_START` | **Delivered** | Computes sample offset to next downbeat each block | +| Length snap to nearest whole bar on stop | **Delivered** | Gated on `pLS->state == STATE_RECORD` | +| Free-run fallback when transport stopped | **Delivered** | | +| `state` port (5-value enumerated integer) replacing `play_pause`+`record` | **Deferred** | Separate PR | +| 5-state cycle wrapper (`Empty`/`Recording`/`Overdub`/`Playback`/`Stopped`) | **Deferred** | Separate PR; the 4-state `STATE_*` internals are unchanged | +| `reset` port (longpress → clear) | **Deferred** | Separate PR | +| modgui skin update for the new URI | **Deferred** | On-device-only, not blocking for v1 | +| `debpkgs/loopjefe-lv2/` in `pi-gen-pistomp` | **Not started** | Blocked on plugin tree landing | + +### `~/dev/mod-host` + +| Item | Status | Notes | +|---|---|---| +| `POSTPONED_BEAT_SYNC` event type in `src/effects.c` | **Delivered** | Uncommitted; adds struct, event-type, and switch case | +| Downbeat crossing detection in `UpdateGlobalJackPosition` | **Delivered** | Back-dates `t_us` to actual downbeat frame via `bar_beat`/`beat_length_frames` math; reuses existing `rtsafe_memory_pool` | +| `g_last_beat_sync_bar` reset on stopped/no-BBT | **Delivered** | Lets a fresh roll re-anchor immediately | +| `clock_gettime(CLOCK_MONOTONIC)` for the anchor `t_us` | **Delivered** | Unconditionally correct; sidesteps any `jack_time_t`-vs-monotonic question | +| `jack_port_get_latency_range` correction (optional ~5ms offset) | **Not started** | Optional, not critical per the latency analysis | + +### `~/dev/mod-ui` + +| Item | Status | Notes | +|---|---|---| +| `beat_sync` relay in `process_read_message_body` | **Delivered** | Uncommitted; `mod/host.py` 7-line addition | +| Browser-side `ws.onmessage` handling | **N/A — no change needed** | `html/js/host.js` silently ignores unrecognized commands; `beat_sync` falls through the if/else chain without matching any branch, no crash, no error. `triggerDelayedReadyResponse(false)` clears the data-ready handshake timer (harmless, next `data_finish` re-arms it). | +| HMI-side handling | **N/A — no change needed** | The HMI WebSocket is inbound-only (HMI sends commands *to* mod-ui, doesn't receive broadcasts). | + +### `~/dev/pi-stomp` + +| Item | Status | Notes | +|---|---|---| +| `BeatSyncMessage` typed message in `ws_protocol.py` | **Delivered** | Uncommitted; one-line docstring: "`t_us` is back-dated to the actual downbeat frame." | +| `parse_message` case for `beat_sync` | **Delivered** | Uncommitted; matches the format `beat_sync ` | +| `pistomp/beatsync.py` (`BeatGrid` + `TickState`) | **Delivered** | Uncommitted; new file. `on_anchor` / `clear` / `tick`. Re-anchors on each `beat_sync`; ticks return `(is_anchored, is_flashing, is_bar_start, bpm, bpb)`. No docstrings — the field names carry the meaning. | +| `tests/test_beatsync.py` (19 tests) | **Delivered** | Uncommitted; covers anchor/tick/flash/clear/stale/re-anchor/frozen-dataclass behaviors | +| `tests/test_ws_protocol.py` (6 parser tests) | **Delivered** | Uncommitted; happy path, edge cases | +| Dispatch `BeatSyncMessage` / `TransportMessage(rolling=False)` in `_handle_ws_message` | **Delivered** | Uncommitted; `modhandler.py` | +| `_drive_metronome` in `poll_indicators` (FS4 strip + GPIO LED flash) | **Delivered** | Uncommitted; `modhandler.py`. Always-on (no pedalboard gate). Strip pixel: `(255,255,255)` on downbeat, `(180,180,180)` on regular beat. GPIO LED: `on()` for any flash. | +| `pyright` + `ruff` clean, 1019 tests pass | **Verified** | Zero regressions | +| Per-track state-cycle tracking (was plan item 1) | **Removed — moved to plugin** | Plugin owns the state machine. The 5-state `state` port echo drives the footswitch LED through the existing `param_set → set_value` chain. No pi-Stomp code needed. | +| Per-track LED state coloring (was plan item 2) | **Deferred to plugin-side PR** | Add a `footswitch_led_color_fn` field to `PluginCustomization` (in `modalapi/plugin_customization.py`), plumb a back-reference from `Footswitch` to the bound `Plugin`, and have `Footswitch.set_led` consult the customization before falling through to the existing category-color path. Then register the loopjefe customization at `pi-stomp/plugins/loopjefe/__init__.py` with a `LABEL → COLOR` map keyed off the `state` port's scalePoint labels (`Empty`/`Recording`/`Overdub`/`Playback`/`Stopped`). The customization hook is general — any plugin with a non-trivial value→LED-color mapping can register a function. See the "Per-track state color" section. | +| Longpress → secondary CC (was plan item 3) | **Deferred** | The `longpress` config enum in `pistomp/config.py` needs to grow arbitrary "send this CC" actions, the same way short-press bindings already do. Until then, the looper's longpress-as-clear binding can be exercised in mod-ui by MIDI-learning a second footswitch CC to the `reset` port. | +| v1 mod.py (mono LCD, no ledstrip) support | **Explicitly out of scope** | v1 has no LED strip; the metronome LED has no v1 surface. The `BeatSyncMessage` parser still recognizes the message; v1's `_handle_ws_message` simply has no `isinstance(msg, BeatSyncMessage)` branch and ignores it. No code change needed in v1. | From f9f9af08c235ec11c277878c265952c6c04ecdf0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:43:08 -0400 Subject: [PATCH 2/8] Add beat-grid transport-slave metronome for loopjefe multitrack looper 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 --- modalapi/modhandler.py | 44 ++++++++++ modalapi/ws_protocol.py | 29 ++++++- pistomp/beatsync.py | 98 +++++++++++++++++++++ tests/test_beatsync.py | 174 ++++++++++++++++++++++++++++++++++++++ tests/test_ws_protocol.py | 36 ++++++++ 5 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 pistomp/beatsync.py create mode 100644 tests/test_beatsync.py diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 2cd7b8543..6de5417d9 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -55,6 +55,7 @@ PedalSnapshotMessage, PluginBypassMessage, TransportMessage, + BeatSyncMessage, AddPluginMessage, RemovePluginMessage, ConnectMessage, @@ -70,6 +71,7 @@ from pistomp.encoder_controller import EncoderController from pistomp.footswitch import Footswitch from pistomp.footswitch_chords import FootswitchChords +from pistomp.beatsync import BeatGrid from pistomp.input.event import ( AnalogEvent, ControllerEvent, @@ -85,6 +87,14 @@ from pathlib import Path +_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 @@ -183,6 +193,8 @@ 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() + def cleanup(self): if self._tuner_muted: self.audiocard.set_output_muted(False) @@ -336,6 +348,33 @@ def poll_controls(self): def poll_indicators(self): if self.hardware: self.hardware.poll_indicators() + self._drive_metronome() + + def _drive_metronome(self): + fs = self._taptempo_footswitch() + if fs is None: + return + state = self.beat_grid.tick(_now_us()) + if state.is_flashing: + if fs.pixel is not None: + rgb = _METRONOME_DOWNBEAT_RGB if state.is_bar_start else _METRONOME_BEAT_RGB + fs.pixel.set_color(rgb) + fs.pixel.set_enable(True) + if fs.led is not None: + fs.led.on() + else: + if fs.pixel is not None: + fs.pixel.set_enable(False) + if fs.led is not None: + fs.led.off() + + def _taptempo_footswitch(self): + if self.hardware is None: + return None + for fs in self.hardware.footswitches: + if fs.taptempo is not None: + return fs + return None def poll_wifi(self): self.wifi_manager.poll() @@ -613,6 +652,11 @@ def _handle_ws_message(self, msg: WebSocketMessage): if self.hardware.taptempo.is_enabled(): fs = next((f for f in self.hardware.footswitches if f.taptempo is self.hardware.taptempo), None) self.update_lcd_fs(footswitch=fs) + if not msg.rolling: + self.beat_grid.clear() + + elif isinstance(msg, BeatSyncMessage): + self.beat_grid.on_anchor(msg) elif isinstance(msg, ParamSetMessage): # Mirror mod-ui's live value: refresh the cache (so a later edit opens diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py index 0d30e4cdd..8f6546539 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -96,14 +96,24 @@ class TransportMessage: bpm: float +@dataclass +class BeatSyncMessage: + """A downbeat occurred. `t_us` is back-dated to the actual downbeat frame (CLOCK_MONOTONIC).""" + + bar: int + t_us: int + bpm: float + bpb: float + + @dataclass class AddPluginMessage: """Plugin present in a (re)connect/load dump, or dynamically added (add ...).""" instance: str # canonical bare form, e.g. "CollisionDrive" - uri: str # LV2 plugin URI - x: float # mod-ui canvas X - y: float # mod-ui canvas Y + uri: str # LV2 plugin URI + x: float # mod-ui canvas X + y: float # mod-ui canvas Y bypassed: bool @@ -119,7 +129,7 @@ class ConnectMessage: """Two ports connected in the active pedalboard (connect ...).""" port_from: str # e.g. "/graph/PluginA/out_L" - port_to: str # e.g. "/graph/PluginB/in_L" + port_to: str # e.g. "/graph/PluginB/in_L" @dataclass @@ -172,6 +182,7 @@ class UnknownMessage: TrueBypassMessage, PluginBypassMessage, TransportMessage, + BeatSyncMessage, AddPluginMessage, RemovePluginMessage, ConnectMessage, @@ -304,6 +315,16 @@ def parse_message(raw_message: str) -> WebSocketMessage: bpm = float(rest.split()[1]) return TransportMessage(rolling=rolling != "0", bpm=bpm) + # Format: beat_sync {bar} {t_us} {bpm} {bpb} + case ["beat_sync", bar, rest]: + t_us, bpm, bpb = rest.split(" ") + return BeatSyncMessage( + bar=int(bar), + t_us=int(t_us), + bpm=float(bpm), + bpb=float(bpb), + ) + 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..2f5d95a5f --- /dev/null +++ b/pistomp/beatsync.py @@ -0,0 +1,98 @@ +# 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 = 80_000 +STALE_AFTER_US = 5_000_000 + + +@dataclass(frozen=True) +class TickState: + is_anchored: bool + is_flashing: bool + is_bar_start: bool + bpm: float + bpb: float + + +class BeatGrid: + def __init__(self) -> None: + self._anchor_t_us: int | None = None + self._anchor_beat_idx: int = 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: + bpb = msg.bpb if msg.bpb > 0 else 0 + self._anchor_t_us = msg.t_us + self._anchor_beat_idx = msg.bar * int(bpb) if bpb else 0 + self._bpm = msg.bpm + self._bpb = msg.bpb + self._last_beat_idx = self._anchor_beat_idx + self._flash_end_us = None + self._last_crossing_was_bar_start = False + + def clear(self) -> None: + self._anchor_t_us = None + self._anchor_beat_idx = 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 + delta_beats = delta_us * self._bpm / 60_000_000.0 + current_beat_idx = self._anchor_beat_idx + int(delta_beats) + + if current_beat_idx > self._last_beat_idx: + self._last_beat_idx = current_beat_idx + self._flash_end_us = now_us + FLASH_US + self._last_crossing_was_bar_start = ( + bpb_int > 0 and (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, + ) diff --git a/tests/test_beatsync.py b/tests/test_beatsync.py new file mode 100644 index 000000000..35790b5b2 --- /dev/null +++ b/tests/test_beatsync.py @@ -0,0 +1,174 @@ +"""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(bar=0, t_us=0, bpm=120.0, bpb=4.0) -> BeatSyncMessage: + return BeatSyncMessage(bar=bar, t_us=t_us, bpm=bpm, bpb=bpb) + + +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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + assert g.is_anchored is True + + def test_anchor_does_not_flash_immediately(self): + g = BeatGrid() + g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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(bar=0, 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(bar=0, 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(bar=0, 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_bar_start_marked_on_downbeat(self): + g = BeatGrid() + g.on_anchor(_anchor(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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(bar=5, 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + g.on_anchor(_anchor(bar=0, 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") diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py index 3810cfa69..f8352b970 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -3,6 +3,7 @@ from modalapi.ws_protocol import ( AddHwPortMessage, AddPluginMessage, + BeatSyncMessage, ConnectMessage, DisconnectMessage, LoadingEndMessage, @@ -235,6 +236,41 @@ def test_transport_malformed_bpm_is_unknown(): ) +# --------------------------------------------------------------------------- +# beat_sync (beat_sync {bar} {t_us} {bpm} {bpb}) +# --------------------------------------------------------------------------- + + +def test_beat_sync_basic(): + assert parse_message("beat_sync 5 1234567890 120.0 4") == BeatSyncMessage( + bar=5, t_us=1234567890, bpm=120.0, bpb=4.0 + ) + + +def test_beat_sync_bar_zero(): + assert parse_message("beat_sync 0 0 60.0 3") == BeatSyncMessage( + bar=0, t_us=0, bpm=60.0, bpb=3.0 + ) + + +def test_beat_sync_fractional_bpb(): + assert parse_message("beat_sync 1 1000000 90.5 7") == BeatSyncMessage( + bar=1, t_us=1000000, bpm=90.5, bpb=7.0 + ) + + +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) From dcca0e8c648e8c4503736c452c4590572b35fcb7 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:59:06 -0400 Subject: [PATCH 3/8] Footswitch longpress: send an arbitrary CC, not just a named callback group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- modalapi/mod.py | 14 ---- modalapi/modhandler.py | 12 --- pistomp/config.py | 38 +++++++-- pistomp/footswitch.py | 11 +++ pistomp/handler.py | 26 +++++- pistomp/hardware.py | 6 +- .../test_handle_footswitch_longpress.py | 81 +++++++++++++++++++ .../test_external_midi_loopback.py | 28 +++---- tests/test_config_schema.py | 57 +++++++++++++ tests/test_footswitch.py | 31 +++++++ tests/test_hardware.py | 33 ++++++++ 11 files changed, 282 insertions(+), 55 deletions(-) create mode 100644 tests/input_router/test_handle_footswitch_longpress.py diff --git a/modalapi/mod.py b/modalapi/mod.py index 4e25c7135..d5651f07f 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -33,7 +33,6 @@ import modalapi.external_midi as ExternalMidi from pistomp.encoder_controller import EncoderController from pistomp.input.event import AnalogEvent, ControllerEvent, EncoderEvent, SwitchEvent, SwitchEventKind -from rtmidi.midiconstants import CONTROL_CHANGE from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute from typing import Optional @@ -261,19 +260,6 @@ def _handle_switch_v1(self, event: SwitchEvent) -> bool: return self._handle_footswitch(c, event.kind, event.timestamp) return False - def _emit_midi(self, controller, midi_value: int) -> None: - if controller.midi_CC is None: - return - cc = [controller.midi_channel | CONTROL_CHANGE, controller.midi_CC, int(midi_value)] - port_name = self.hardware.external_port_name(controller) - if port_name is not None and self.external_midi is not None: - try: - if self.external_midi.send_raw(port_name, cc): - return - except Exception as e: - logging.warning("External CC send failed on %s: %s", port_name, e) - self.hardware.midiout.send_message(cc) - def add_lcd(self, lcd): self.lcd = lcd diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 6de5417d9..feccb69c4 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -83,7 +83,6 @@ from pistomp.tuner import TunerPanel, TunerSourceFactory from pistomp.tuner.client import TunerClient from pistomp.tuner.engine import TunerBackend, TunerEngine -from rtmidi.midiconstants import CONTROL_CHANGE from pathlib import Path @@ -321,17 +320,6 @@ def _handle_switch(self, event: SwitchEvent) -> bool: return self._handle_footswitch(controller, event.kind, event.timestamp) return False - 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: - return - cc = [controller.midi_channel | CONTROL_CHANGE, controller.midi_CC, int(midi_value)] - port_name = self.hardware.external_port_name(controller) - if port_name is not None and self.external_midi is not None: - if self.external_midi.send_raw(port_name, cc): - return - self.hardware.midiout.send_message(cc) - def add_lcd(self, lcd): self._lcd = lcd diff --git a/pistomp/config.py b/pistomp/config.py index e587b5fd7..095f8a51a 100644 --- a/pistomp/config.py +++ b/pistomp/config.py @@ -25,6 +25,15 @@ DEFAULT_CONFIG_FILE = "default_config.yml" +LONGPRESS_GROUP_NAMES = [ + "next_snapshot", + "previous_snapshot", + "toggle_bypass", + "set_mod_tap_tempo", + "toggle_tap_tempo_enable", + "toggle_tuner_enable", +] + schema = { "$schema": "http://json-schema.org/draft-04/schema#", "type": "object", @@ -83,11 +92,30 @@ "type": "integer" }, "longpress": { - "type" : ["array", "string"], - "items" : { - "type" : "string", - "enum" : ["next_snapshot", "previous_snapshot", "toggle_bypass", "set_mod_tap_tempo", "toggle_tap_tempo_enable"] - } + "oneOf": [ + { + "type": "string", + "enum": LONGPRESS_GROUP_NAMES + }, + { + "type": "array", + "items": { + "type": "string", + "enum": LONGPRESS_GROUP_NAMES + } + }, + { + "type": "object", + "description": "Send this CC (value 127) on longpress instead of resolving a named longpress group.", + "properties": { + "midi_CC": { + "type": "integer" + } + }, + "required": ["midi_CC"], + "additionalProperties": False + } + ] }, "midi_CC": { "type": "integer" diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index c5cab07e0..ca7d4bb49 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -42,6 +42,7 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.category = None self.pixel = pixel self.longpress_groups = [] + self.longpress_midi_CC = None self.disabled = False self.taptempo = taptempo @@ -134,6 +135,16 @@ def set_category(self, category): def set_lcd_color(self, color): self.lcd_color = color + def set_longpress(self, value): + """Apply the raw 'longpress' config value: a group name, a list of + group names, a {midi_CC: N} object, or None.""" + if isinstance(value, dict): + self.longpress_midi_CC = value.get(Token.MIDI_CC) + self.longpress_groups = [] + else: + self.longpress_midi_CC = None + self.set_longpress_groups(value) + def set_longpress_groups(self, groups): if groups is None: self.longpress_groups = [] diff --git a/pistomp/handler.py b/pistomp/handler.py index aaad27fb2..c13407780 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -16,8 +16,11 @@ from __future__ import annotations +import logging from typing import TYPE_CHECKING, Any +from rtmidi.midiconstants import CONTROL_CHANGE + from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.current import Current from pistomp.encoder_controller import EncoderController @@ -143,6 +146,10 @@ def _handle_footswitch(self, fs: "Footswitch", kind: SwitchEventKind, timestamp: fs.toggle_relays(new_toggled) fs.set_led(new_toggled) self.update_lcd_fs(bypass_change=True) + elif fs.longpress_midi_CC is not None: + # Momentary trigger CC, distinct from the short-press binding + # (e.g. a plugin's "reset" port learned to this CC). + self._emit_midi(fs, 127, cc=fs.longpress_midi_CC) else: # TODO: consider case where relay and longpress are specified self.chord_helper.observe(fs, timestamp) @@ -174,8 +181,23 @@ def _tick_chords(self) -> None: if cb: cb() - def _emit_midi(self, controller, midi_value: int) -> None: - raise NotImplementedError() + def _emit_midi(self, controller, midi_value: int, cc: int | None = None) -> None: + """Send a CC via this controller's binding, or an explicit override CC + (e.g. a footswitch's longpress trigger, distinct from its short-press + binding). Tries the controller's routed external port; falls back to + the virtual MIDI Through port.""" + cc_num = cc if cc is not None else controller.midi_CC + if cc_num is None: + return + message = [controller.midi_channel | CONTROL_CHANGE, cc_num, int(midi_value)] + port_name = self.hardware.external_port_name(controller) + if port_name is not None and self.hardware.external_midi is not None: + try: + if self.hardware.external_midi.send_raw(port_name, message): + return + except Exception as e: + logging.warning("External CC send failed on %s: %s", port_name, e) + self.hardware.midiout.send_message(message) def cleanup(self): raise NotImplementedError() diff --git a/pistomp/hardware.py b/pistomp/hardware.py index f40e0390c..4db79a9ff 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -512,9 +512,9 @@ def __init_footswitches(self, cfg): if Token.COLOR in f: fs.set_lcd_color(f[Token.COLOR]) - # Longpress and longpress groups - if Token.LONGPRESS in f: # Can be a list or a single (string) - fs.set_longpress_groups(Util.DICT_GET(f, Token.LONGPRESS)) + # Longpress: a group name, a list of group names, or {midi_CC: N} + if Token.LONGPRESS in f: + fs.set_longpress(Util.DICT_GET(f, Token.LONGPRESS)) idx += 1 diff --git a/tests/input_router/test_handle_footswitch_longpress.py b/tests/input_router/test_handle_footswitch_longpress.py new file mode 100644 index 000000000..4215b8af8 --- /dev/null +++ b/tests/input_router/test_handle_footswitch_longpress.py @@ -0,0 +1,81 @@ +"""Handler._handle_footswitch longpress dispatch: relay > longpress_midi_CC > chord group. + +Uses the real _handle_footswitch from pistomp.handler.Handler with a mocked +Hardware, so the priority order between the three longpress behaviors is +exercised as production code, not re-implemented. +""" + +from unittest.mock import MagicMock + +from pistomp.footswitch import Footswitch +from pistomp.handler import Handler +from pistomp.input.event import SwitchEventKind + + +class _TestHandler(Handler): + def __init__(self, hw): + super().__init__() + self.hardware = hw + + def update_lcd_fs(self, footswitch=None, bypass_change=False): + pass + + def get_callback(self, callback_name): + return None + + def handle(self, event): + raise NotImplementedError + + +def _make_handler(): + hw = MagicMock() + hw.external_port_name.return_value = None + hw.external_midi = None + return _TestHandler(hw), hw + + +def _make_footswitch(**kwargs): + return Footswitch( + id=kwargs.get("id", 1), + led_pin=None, + pixel=None, + midi_CC=kwargs.get("midi_CC", 10), + midi_channel=kwargs.get("midi_channel", 0), + refresh_callback=lambda **kw: None, + ) + + +class TestLongpressMidiCC: + def test_sends_longpress_cc_not_short_press_cc(self): + handler, hw = _make_handler() + fs = _make_footswitch(midi_CC=10) + fs.longpress_midi_CC = 65 + + handler._handle_footswitch(fs, SwitchEventKind.LONGPRESS, timestamp=1.0) + + hw.midiout.send_message.assert_called_once() + message = hw.midiout.send_message.call_args[0][0] + assert message[1] == 65 + assert message[2] == 127 + + def test_relay_takes_priority_over_longpress_midi_cc(self): + handler, hw = _make_handler() + fs = _make_footswitch() + fs.longpress_midi_CC = 65 + relay = MagicMock() + relay.init_state.return_value = False + fs.add_relay(relay) + + handler._handle_footswitch(fs, SwitchEventKind.LONGPRESS, timestamp=1.0) + + hw.midiout.send_message.assert_not_called() + assert relay.enable.called or relay.disable.called + + def test_no_longpress_midi_cc_falls_through_to_chord(self): + handler, hw = _make_handler() + fs = _make_footswitch() + fs.longpress_groups = ["toggle_bypass"] + + handler._handle_footswitch(fs, SwitchEventKind.LONGPRESS, timestamp=1.0) + + hw.midiout.send_message.assert_not_called() diff --git a/tests/integration/test_external_midi_loopback.py b/tests/integration/test_external_midi_loopback.py index fe0d83ef2..cae0c13d7 100644 --- a/tests/integration/test_external_midi_loopback.py +++ b/tests/integration/test_external_midi_loopback.py @@ -24,12 +24,13 @@ class _FakeHardware(Hardware): """Minimal hardware stub: just enough for _emit_midi to resolve routing.""" - def __init__(self, port_name: str): + def __init__(self, port_name: str, mgr: ExternalMidiManager): # Hardware.__init__ takes (default_config, handler, midiout, refresh_callback); # skip it — this fake doesn't need a config or real handler/refresh. self._port_name = port_name self.midiout = MagicMock() self.external_routing: dict = {} + self.external_midi = mgr def external_port_name(self, controller: Controller) -> str | None: info = self.external_routing.get(controller) @@ -53,22 +54,11 @@ class _LoopbackHandler(Handler): footswitch path exercises production code. """ - def __init__(self, hw: _FakeHardware, mgr: ExternalMidiManager): + def __init__(self, hw: _FakeHardware): super().__init__() self.hardware = hw - self.external_midi = mgr # chord_helper is set by Handler.__init__; we leave it as-is. - def _emit_midi(self, controller, midi_value: int) -> None: - if controller.midi_CC is None: - return - cc = [controller.midi_channel | CONTROL_CHANGE, controller.midi_CC, int(midi_value)] - port_name = self.hardware.external_port_name(controller) - if port_name is not None: - if self.external_midi.send_raw(port_name, cc): - return - self.hardware.midiout.send_message(cc) - def update_lcd_fs(self, footswitch=None, bypass_change=False): pass @@ -208,8 +198,8 @@ def test_footswitch_press_reaches_real_port(self, loopback): mgr = _manager_for(port_name) mgr.open_port(port_name) - hw = _FakeHardware(port_name) - handler = _LoopbackHandler(hw, mgr) + hw = _FakeHardware(port_name, mgr) + handler = _LoopbackHandler(hw) fs = Footswitch(id=0, led_pin=None, pixel=None, midi_CC=75, midi_channel=0xB0, refresh_callback=lambda **kw: None) @@ -226,8 +216,8 @@ def test_tweak_encoder_rotation_reaches_real_port(self, loopback): mgr = _manager_for(port_name) mgr.open_port(port_name) - hw = _FakeHardware(port_name) - handler = _LoopbackHandler(hw, mgr) + hw = _FakeHardware(port_name, mgr) + handler = _LoopbackHandler(hw) enc = EncoderController(d_pin=None, clk_pin=None, midi_channel=0xB0, midi_CC=7) hw.external_routing[enc] = RoutingInfo.external(port_name) @@ -244,8 +234,8 @@ def test_expression_movement_reaches_real_port(self, loopback): mgr = _manager_for(port_name) mgr.open_port(port_name) - hw = _FakeHardware(port_name) - handler = _LoopbackHandler(hw, mgr) + hw = _FakeHardware(port_name, mgr) + handler = _LoopbackHandler(hw) spi = MagicMock() spi.readChannel.return_value = 512 diff --git a/tests/test_config_schema.py b/tests/test_config_schema.py index 1223e86cb..282aa2c2b 100644 --- a/tests/test_config_schema.py +++ b/tests/test_config_schema.py @@ -60,3 +60,60 @@ def test_non_string_midi_port_rejected(): } with pytest.raises(exceptions.ValidationError): validate(instance=cfg, schema=schema) + + +def test_longpress_group_name_accepted(): + cfg = { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "longpress": "toggle_bypass"}], + } + } + validate(instance=cfg, schema=schema) + + +def test_longpress_group_name_list_accepted(): + cfg = { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "longpress": ["next_snapshot", "toggle_bypass"]}], + } + } + validate(instance=cfg, schema=schema) + + +def test_longpress_midi_cc_object_accepted(): + cfg = { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "longpress": {"midi_CC": 65}}], + } + } + validate(instance=cfg, schema=schema) + + +def test_longpress_unknown_group_name_rejected(): + cfg = { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "longpress": "not_a_real_group"}], + } + } + with pytest.raises(exceptions.ValidationError): + validate(instance=cfg, schema=schema) + + +def test_longpress_object_without_midi_cc_rejected(): + cfg = { + "hardware": { + "version": 3.0, + "midi": {"channel": 14}, + "footswitches": [{"id": 0, "longpress": {}}], + } + } + with pytest.raises(exceptions.ValidationError): + validate(instance=cfg, schema=schema) diff --git a/tests/test_footswitch.py b/tests/test_footswitch.py index c0f939171..8f0b89803 100644 --- a/tests/test_footswitch.py +++ b/tests/test_footswitch.py @@ -59,6 +59,37 @@ def test_set_longpress_groups_none_clears(self): assert fs.longpress_groups == [] +class TestSetLongpress: + """set_longpress is the config-facing entry point: dispatches between the + named-group form (str/list/None) and the {midi_CC: N} form.""" + + def test_string_sets_groups(self): + with _make_footswitch() as (fs, _sink): + fs.set_longpress("toggle_bypass") + assert fs.longpress_groups == ["toggle_bypass"] + assert fs.longpress_midi_CC is None + + def test_list_sets_groups(self): + with _make_footswitch() as (fs, _sink): + fs.set_longpress(["next_snapshot", "toggle_bypass"]) + assert fs.longpress_groups == ["next_snapshot", "toggle_bypass"] + assert fs.longpress_midi_CC is None + + def test_none_clears_both(self): + with _make_footswitch() as (fs, _sink): + fs.set_longpress({"midi_CC": 65}) + fs.set_longpress(None) + assert fs.longpress_groups == [] + assert fs.longpress_midi_CC is None + + def test_dict_sets_midi_cc_and_clears_groups(self): + with _make_footswitch() as (fs, _sink): + fs.set_longpress_groups(["toggle_bypass"]) + fs.set_longpress({"midi_CC": 65}) + assert fs.longpress_midi_CC == 65 + assert fs.longpress_groups == [] + + class TestOnSwitch: def test_short_press_dispatches_press_event(self): with _make_footswitch() as (fs, sink): diff --git a/tests/test_hardware.py b/tests/test_hardware.py index 502f630ef..54469c37b 100644 --- a/tests/test_hardware.py +++ b/tests/test_hardware.py @@ -8,6 +8,7 @@ import common.token as Token from modalapi.external_midi import ExternalMidiManager +from pistomp.footswitch import Footswitch from pistomp.hardware import Hardware @@ -141,6 +142,38 @@ 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 TestInitFootswitchesLongpress: + """__init_footswitches dispatches the 'longpress' config value to + Footswitch.set_longpress: a group name (or list) vs. a {midi_CC: N} object.""" + + def _hw_with_footswitch(self, fs): + hw = object.__new__(_StubHardware) + hw.footswitches = [fs] + return hw + + def test_group_name_sets_longpress_groups(self): + fs = Footswitch(id=0, led_pin=None, pixel=None, midi_CC=None, midi_channel=0, + refresh_callback=lambda **kw: None) + hw = self._hw_with_footswitch(fs) + cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0, Token.LONGPRESS: "toggle_bypass"}]}} + _init_footswitches(hw, cfg) + assert fs.longpress_groups == ["toggle_bypass"] + assert fs.longpress_midi_CC is None + + def test_midi_cc_object_sets_longpress_midi_cc(self): + fs = Footswitch(id=0, led_pin=None, pixel=None, midi_CC=None, midi_channel=0, + refresh_callback=lambda **kw: None) + hw = self._hw_with_footswitch(fs) + cfg = {Token.HARDWARE: {Token.FOOTSWITCHES: [{Token.ID: 0, Token.LONGPRESS: {"midi_CC": 65}}]}} + _init_footswitches(hw, cfg) + assert fs.longpress_midi_CC == 65 + assert fs.longpress_groups == [] + + class TestReinitDefaultRouting: def test_reinit_applies_routing_for_default_cfg(self, monkeypatch): """Routing is applied for the default config, not only for pedalboard cfg.""" From 147d40bdaf607a0a280e00162e78e1ebb95369ca Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:59:18 -0400 Subject: [PATCH 4/8] docs: mark longpress-as-secondary-CC as delivered in looper plan Co-Authored-By: Claude Sonnet 5 --- docs/multitrack-looper-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/multitrack-looper-plan.md b/docs/multitrack-looper-plan.md index b23215aeb..73613d9cc 100644 --- a/docs/multitrack-looper-plan.md +++ b/docs/multitrack-looper-plan.md @@ -834,5 +834,5 @@ listed as "delivered" is implemented in the working tree but | `pyright` + `ruff` clean, 1019 tests pass | **Verified** | Zero regressions | | Per-track state-cycle tracking (was plan item 1) | **Removed — moved to plugin** | Plugin owns the state machine. The 5-state `state` port echo drives the footswitch LED through the existing `param_set → set_value` chain. No pi-Stomp code needed. | | Per-track LED state coloring (was plan item 2) | **Deferred to plugin-side PR** | Add a `footswitch_led_color_fn` field to `PluginCustomization` (in `modalapi/plugin_customization.py`), plumb a back-reference from `Footswitch` to the bound `Plugin`, and have `Footswitch.set_led` consult the customization before falling through to the existing category-color path. Then register the loopjefe customization at `pi-stomp/plugins/loopjefe/__init__.py` with a `LABEL → COLOR` map keyed off the `state` port's scalePoint labels (`Empty`/`Recording`/`Overdub`/`Playback`/`Stopped`). The customization hook is general — any plugin with a non-trivial value→LED-color mapping can register a function. See the "Per-track state color" section. | -| Longpress → secondary CC (was plan item 3) | **Deferred** | The `longpress` config enum in `pistomp/config.py` needs to grow arbitrary "send this CC" actions, the same way short-press bindings already do. Until then, the looper's longpress-as-clear binding can be exercised in mod-ui by MIDI-learning a second footswitch CC to the `reset` port. | +| Longpress → secondary CC (was plan item 3) | **Delivered** | `longpress` now accepts `{midi_CC: N}` alongside the existing named-group form (string/list); `Footswitch.set_longpress` dispatches between them, and `Handler._handle_footswitch` sends a momentary CC=127 via a consolidated `_emit_midi(controller, value, cc=...)` on the shared `Handler` base (previously duplicated per-subclass). The looper's longpress-as-clear binding (short-press CC → `state`, longpress CC → `reset`) can now be configured directly in `config.yml`, no mod-ui workaround needed. | | v1 mod.py (mono LCD, no ledstrip) support | **Explicitly out of scope** | v1 has no LED strip; the metronome LED has no v1 surface. The `BeatSyncMessage` parser still recognizes the message; v1's `_handle_ws_message` simply has no `isinstance(msg, BeatSyncMessage)` branch and ignores it. No code change needed in v1. | From bfdf7d2b50e5ed4529e85bceef9bc0d01b0b156d Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 22:19:55 -0400 Subject: [PATCH 5/8] Fix LED/pixel driver conflict --- modalapi/modhandler.py | 18 ++++++++++++------ pistomp/handler.py | 1 - 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index feccb69c4..5095c339a 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -193,6 +193,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") self.chord_helper = FootswitchChords() self.beat_grid = BeatGrid() + self._taptempo_fs_cache: Footswitch | None = None def cleanup(self): if self._tuner_muted: @@ -343,6 +344,11 @@ def _drive_metronome(self): if fs is None: return state = self.beat_grid.tick(_now_us()) + if not state.is_anchored: + # Unanchored: leave the LED/pixel alone so Footswitch.set_led's + # own taptempo blink (or normal toggle state) isn't clobbered + # every 20ms tick. + return if state.is_flashing: if fs.pixel is not None: rgb = _METRONOME_DOWNBEAT_RGB if state.is_bar_start else _METRONOME_BEAT_RGB @@ -357,12 +363,12 @@ def _drive_metronome(self): fs.led.off() def _taptempo_footswitch(self): - if self.hardware is None: - return None - for fs in self.hardware.footswitches: - if fs.taptempo is not None: - return fs - return None + 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 poll_wifi(self): self.wifi_manager.poll() diff --git a/pistomp/handler.py b/pistomp/handler.py index c13407780..fff849952 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -151,7 +151,6 @@ def _handle_footswitch(self, fs: "Footswitch", kind: SwitchEventKind, timestamp: # (e.g. a plugin's "reset" port learned to this CC). self._emit_midi(fs, 127, cc=fs.longpress_midi_CC) else: - # TODO: consider case where relay and longpress are specified self.chord_helper.observe(fs, timestamp) return True From d94470f13b55c12ab33d33ab3fbbab9966f4f9cf Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 6 Jul 2026 16:32:17 -0400 Subject: [PATCH 6/8] WIP loopjefe footswitch refactor --- docs/plan-footswitch-behaviour.md | 303 ++++++++++++++++++ modalapi/footswitch_behavior.py | 81 +++++ modalapi/mod.py | 20 ++ modalapi/modhandler.py | 145 +++++++-- modalapi/plugin_customization.py | 4 + modalapi/websocket_bridge.py | 33 +- modalapi/ws_protocol.py | 16 + pistomp/beatsync.py | 3 + pistomp/controller_manager.py | 2 + pistomp/footswitch.py | 30 +- pistomp/handler.py | 26 +- pistomp/hardware.py | 7 + plugins/__init__.py | 1 + plugins/loopjefe/__init__.py | 88 +++++ tests/conftest.py | 4 + .../test_handle_footswitch_longpress.py | 66 ++++ tests/test_beatsync.py | 37 +++ tests/test_loopjefe_behavior.py | 132 ++++++++ tests/test_websocket_bridge.py | 76 +++++ tests/test_ws_protocol.py | 31 ++ tests/v3/test_footswitch_led_driver.py | 191 +++++++++++ tests/v3/test_footswitch_presets.py | 30 +- tests/v3/test_midi_learn.py | 109 +++++++ tests/v3/test_taptempo_led.py | 155 +++++++++ 24 files changed, 1541 insertions(+), 49 deletions(-) create mode 100644 docs/plan-footswitch-behaviour.md create mode 100644 modalapi/footswitch_behavior.py create mode 100644 plugins/loopjefe/__init__.py create mode 100644 tests/test_loopjefe_behavior.py create mode 100644 tests/v3/test_footswitch_led_driver.py create mode 100644 tests/v3/test_taptempo_led.py diff --git a/docs/plan-footswitch-behaviour.md b/docs/plan-footswitch-behaviour.md new file mode 100644 index 000000000..98eeaed85 --- /dev/null +++ b/docs/plan-footswitch-behaviour.md @@ -0,0 +1,303 @@ +# Plan: Generic plugin-owned footswitch behavior (LED + press semantics), loopjefe first consumer + +## Context + +pi-Stomp is gaining an RC-505-style multitrack looper (`loopjefe-lv2`) controlled +from footswitches. The plugin was redesigned and the earlier plan +(`docs/multitrack-looper-plan.md`) is now materially stale in two ways that break +pi-Stomp's assumptions: + +- **`state` is now a read-only OUTPUT control port with 9 states** (Empty=0, + Record Arm=1, Recording=2, Record Close=3, Playback=4, Stopped=5, Overdub + Arm=6, Overdub=7, Overdub Close=8). Output ports reach pi-Stomp as `output_set`, + **not** `param_set` — and pi-Stomp currently drops *all* `output_set` at the WS + bridge (`websocket_bridge.py:207`, an audio-meter flood). So the old + "`param_set` echo → footswitch LED" path no longer fires for `state`. +- **Control is via edge-triggered, self-clearing input trigger ports**: `advance` + (short-press), `reset` (long-press), plus `undo`/`redo`. pi-Stomp's short-press + today alternates `127/0` (`handler.py:168-170`), so every other press sends `0` + — a dead no-op for a rising-edge trigger. The self-clearing behavior is + documented in `loopjefe-lv2/docs/state-machine-redesign.md`, `CLAUDE.md`, + `src/state_machine.h` (implementation), and tested in + `tests/test_state_ports_contract.cpp`. `undo`/`redo` are NOT self-clearing + (the host must clear them). + +Rather than hardcode looper knowledge into the handler, the goal is a **generic +plugin-customization mechanism**: when a footswitch is MIDI-bound (via mod-ui +MIDI-learn, reflected in the pedalboard TTL) to a plugin's parameter, pi-Stomp +looks up that plugin's registered `PluginCustomization` and lets it own the +footswitch's behavior — press semantics (momentary vs toggle), which of the +plugin's OUTPUT ports to subscribe to, and per-tick LED rendering (color as a +function of cached state, beat-synced pulsing). Any future plugin with a +non-trivial control surface registers a customization with **zero new handler +code**. + +The FS4 `beat_sync`→`BeatGrid` metronome already works and is unaffected; this +plan generalizes the *per-track* footswitch behavior and adds the `output_set` +plumbing it needs. Confirmed product decisions: LED color is a pure function of +the 9-state value (matching loopjefe's MODGUI, **not** per-track colors); all +active states beat-pulse (bright on beat, brightest on the bar downbeat, dim +between), including the blue shoulder states; Stopped is steady grey; Empty is +off. + +## Key facts established during exploration + +- **Footswitch → Parameter back-ref exists** (`Controller.bind_to_parameter` sets + `self.parameter`, `controller.py:89`), giving `fs.parameter.instance_id` + + `fs.parameter.symbol`. **Footswitch → Plugin does NOT exist** — only the reverse + `plugin.controllers.append(controller)` (`controller_manager.py:90`). We do NOT + add a forward ref; the behavior factory `footswitch_behavior_fn(plugin)` receives + the plugin and captures whatever it needs (instance_id, customization fields) on + the behavior object itself. +- **Every footswitch always has a behavior.** `ControllerManager.bind()` attaches + either a plugin-provided behavior or a `DefaultFootswitchBehavior` (toggle + semantics, category color, no WS subscriptions). This eliminates all `None` + guards on `fs.behavior` in the driver and dispatch code. +- **`set_led()` and `set_category()` no longer drive the pixel.** The old baked-in + behavior (`set_led` called `pixel.set_enable`, `set_category` called + `pixel.set_color_by_category`) was removed. Pixel rendering is now entirely + owned by `_drive_footswitch_leds` in `poll_indicators`. `set_led` only handles + the GPIO LED (for immediate press feedback); `set_category` only stores the + category for the default behavior to read. +- **Binding happens in `ControllerManager.bind()`** (`controller_manager.py:67-101`), + called on every pedalboard/state change; loopjefe's `advance` port (MIDI-learned) + gets a TTL `midi:binding`, so the short-press footswitch binds to + `parameter.symbol == "advance"` on the loopjefe instance. +- **`PluginCustomization`** (`modalapi/plugin_customization.py:30`) is a frozen + dataclass looked up by URI (`plugins/customization.py` `register`/`lookup`) and + stored on `Plugin` at load (`pedalboard.py:228-237`). Existing consumers under + `plugins/*/__init__.py` (~30 packages). +- **LED surfaces**: `fs.pixel` (RGB `Pixel`, `ledstrip.py:51` — `set_color` stores, + `set_enable(True)` renders) and `fs.led` (GPIO on/off/blink). `set_led` + (`footswitch.py:115`) only toggles enable; color comes from `set_category` → + `Category.get_category_color`. +- **Per-tick LED driver today is only `_drive_metronome`** (`modhandler.py:343`), + hard-targeting the taptempo FS with hardcoded colors, called from + `poll_indicators` (~20ms). `BeatGrid.tick(now)` → `TickState(is_anchored, + is_flashing, is_bar_start, bpm, bpb)` (`beatsync.py`). This is the insertion + point for a generic per-footswitch driver. +- **WS receive**: `output_set ` frames dropped cheaply by prefix at + `websocket_bridge.py:207` (on the `WebSocketWorker` thread) *before* parsing. + `param_set /graph/ ` and `output_set /graph/ ` + share shape. No `OutputSetMessage` type exists; no subscription concept exists. + `_handle_ws_message` (`modhandler.py:531`) is an isinstance ladder; ParamSet is + matched to a plugin by O(n) scan on `instance_id`. + +## Design overview + +Introduce a **`FootswitchBehavior`** — a per-binding object a plugin's +customization creates at bind time and attaches to the bound footswitch. It owns: + +1. **Press semantics** — `momentary: bool` (short-press sends a one-shot `127` + instead of toggling). Checked by `_handle_footswitch`; no separate + `momentary_symbols` field needed on `PluginCustomization`. +2. **Output subscriptions** — `output_subscriptions() -> Iterable[str]`: symbols + on *this instance* whose `output_set` the behavior wants (e.g. `["state"]`). +3. **Value intake** — `on_output(symbol, value)` caches incoming state. +4. **Per-tick color** — `led_color(beat: TickState) -> tuple[int,int,int] | None`: + returns the color for this tick (None = off). The behavior never computes + brightness — that's the driver's job. +5. **Per-tick style** — `led_style(beat: TickState) -> LedDisplayStyle`: returns + `SOLID` or `METRONOME` for this tick. Defaults to `SOLID`. + +`PluginCustomization` grows one optional field: +- `footswitch_behavior_fn: Callable[[Plugin], FootswitchBehavior] | None = None` + (stored with `compare=False, hash=False` like the other `*_fn` fields). When set, + the factory is called at bind time; when unset, a `DefaultFootswitchBehavior` is + used instead. Every footswitch always has a behavior — no `None` guards needed. + +The WS bridge gains an **atomically-swappable interesting-set** of +`"instance/symbol"` keys; subscribed `output_set` frames survive the prefix drop, +flow through a new `OutputSetMessage`, and are dispatched to the owning behavior. +De-registration is automatic — the interesting-set is fully recomputed on every +`ControllerManager.bind()` call (pedalboard load/rebind), and the old set is +atomically swapped out. + +A new **`_drive_footswitch_leds(beat)`** in `poll_indicators` computes the beat +tick once and asks every behavior-bearing footswitch to render. + +## Changes by area + +### 1. Momentary short-press (standalone, required) + +- `pistomp/handler.py` `_handle_footswitch` short-press arm (`~167-170`): if + `fs.behavior.momentary`, emit `_emit_midi(fs, 127)` every press (no `toggled` + flip). Otherwise keep the existing toggle behavior. No plugin back-ref needed + — the behavior carries `momentary`. +- Longpress already sends momentary `127` (`handler.py:152`) — unchanged; covers + `reset`. + +### 2. Behavior attachment (every footswitch always has a behavior) + +- `pistomp/footswitch.py`: add `self.behavior: "FootswitchBehavior | None = None` + in `__init__`; clear it in `clear_pedalboard_info` (`footswitch.py:187`). + `set_led()` no longer drives `fs.pixel`; `set_category()` no longer calls + `pixel.set_color_by_category`. Pixel rendering is entirely owned by the + per-tick driver. +- `pistomp/controller_manager.py` `bind()` (`~85-90`, the `isinstance(controller, + Footswitch)` block): if `plugin.customization.footswitch_behavior_fn` is set, + call it; if it returns a behavior, attach it; otherwise fall back to + `DefaultFootswitchBehavior(controller)`. +- Type-only import of `FootswitchBehavior` (use `TYPE_CHECKING` / string + annotations). + +### 3. `FootswitchBehavior` protocol + `DefaultFootswitchBehavior` + `LedDisplayStyle` + +- New `modalapi/footswitch_behavior.py`: + ```python + class LedDisplayStyle(Enum): + SOLID = auto() # steady color (default) + METRONOME = auto() # driver pulses brightness on the beat grid + + class FootswitchBehavior(Protocol): + @property + def momentary(self) -> bool: ... + def output_subscriptions(self) -> Iterable[str]: ... + def on_output(self, symbol: str, value: float) -> None: ... + def led_color(self, beat: TickState) -> tuple[int, int, int] | None: ... + def led_style(self, beat: TickState) -> LedDisplayStyle: ... + + class DefaultFootswitchBehavior: + """Built-in: toggle semantics, category color, no WS subscriptions.""" + def __init__(self, fs: Footswitch) -> None: ... + ``` + **Key separation:** the behavior returns *what color + which style*, never the + per-tick brightness. The generic driver (§5) owns the metronome gradient. A + behavior varies only its returned *color* over time (e.g. loopjefe returns a + distinct color when `measure_number == 0`). No `LedRender` dataclass — two + methods are cleaner. +- Add `footswitch_behavior_fn` to `PluginCustomization` + (`modalapi/plugin_customization.py:30`). + +### 4. `output_set` subscription filter + typed message + dispatch + +- `modalapi/websocket_bridge.py`: + - Add `self._interesting: frozenset[str] = frozenset()` and + `set_interesting_outputs(keys: frozenset[str])` (atomic reference swap — no + lock needed for replace + membership read under the GIL). + - In `_receive_messages` (`~207`), replace the unconditional `output_set ` drop + with: cheap `split(" ", 3)`, build key `inst_no_prefix + "/" + symbol`, keep + only if in `self._interesting`, else `continue` (drop as today). +- `modalapi/ws_protocol.py`: add `OutputSetMessage(instance, symbol, value)` + dataclass + a `case ["output_set", path, rest]` in `parse_message` (mirror the + `param_set` arm at `:290`), add to the `WebSocketMessage` union. +- `modalapi/modhandler.py`: + - After `ControllerManager.bind()` (pedalboard load/rebind), assemble the + interesting-set by scanning footswitches with behaviors: + `{f"{fs.parameter.instance_id}/{sym}" for fs in ... for sym in + fs.behavior.output_subscriptions()}` and push via + `ws_bridge.set_interesting_outputs(...)`. + - Add an `isinstance(msg, OutputSetMessage)` arm to `_handle_ws_message` + (`~531`): find footswitch(es) whose behavior stores a matching `instance_id` + (captured at construction from the plugin), call + `behavior.on_output(msg.symbol, msg.value)`. + +### 5. Generic per-tick LED driver + +- `modalapi/modhandler.py` `poll_indicators` (`338`): compute + `beat = self.beat_grid.tick(_now_us())` **once**; pass it to the existing + `_drive_metronome(beat)` (FS4) and to a new `_drive_footswitch_leds(beat)`. +- `_drive_footswitch_leds(beat)`: for each `fs` in `hardware.footswitches`, call + `color = fs.behavior.led_color(beat)` and `style = fs.behavior.led_style(beat)`. + Every footswitch always has a behavior — no `None` guard needed. + - `SOLID` → show `color` steadily (or off when `color is None`). + - `METRONOME` → the driver scales `color` brightness by the beat envelope + (below), so the pulse is uniform across all metronome LEDs regardless of + plugin. When the grid is unanchored (`not beat.is_anchored`) → show `color` + steadily (no pulse). + Apply to `fs.pixel` (`set_color(scaled); set_enable(True)` / `set_enable(False)` + when `None`) and `fs.led` (`on()`/`off()`), mirroring `_drive_metronome`. +- **Beat envelope / gradient.** To make `METRONOME` a real gradient (not a square + 80ms flash), extend `BeatGrid.tick`/`TickState` (`pistomp/beatsync.py`) to expose + a normalized within-beat phase (e.g. `beat_phase: float` in `[0,1)` = elapsed + fraction of the current beat) plus the existing `is_bar_start`. The driver maps + phase→brightness with a decay envelope (bright at phase 0, decaying toward the + next beat), brightest on the bar downbeat. This is a small additive change; the + existing `is_flashing`/`_drive_metronome` path stays working. +- Keep `_drive_metronome` (FS4, not plugin-bound) separate for now; both share the + one `beat` tick computed in `poll_indicators`. Note possible future unification. + +### 6. loopjefe plugin package (`plugins/loopjefe/__init__.py`) + +- `register(*LOOPJEFE_URIS, customization=PluginCustomization( + display_name="LoopJefe", + footswitch_behavior_fn=make_loopjefe_behavior))`. + URIs: `http://treefallsound.com/plugins/loopjefe` and `.../loopjefe-2x2`. +- `LoopjefeBehavior`: `momentary = True`; + `output_subscriptions() -> ("state", "measure_number")`; caches `state:int` and + `measure_number:int` from `on_output`. `led_color(beat)` and `led_style(beat)` + — **no brightness math** (the driver owns the gradient): + - **Style by state**: Empty(0) → `(None, SOLID)` (off); Stopped(5) → + `((80,80,80), SOLID)` (steady grey); all other states → `(color, METRONOME)`. + - **Base color by state** (sensible defaults, tune later): Record Arm(1)/Record + Close(3) → blue `(0,80,255)`; Recording(2) → red `(255,0,0)`; Playback(4) → + green `(0,255,0)`; Overdub Arm(6)/Overdub Close(8) → blue `(0,80,255)`; + Overdub(7) → orange `(255,140,0)`. + - **Loop-downbeat color swap**: when `measure_number == 0` (the loop's own + first measure), return a **distinct variant** of the state color (e.g. a + whiter/brighter tint) so the loop downbeat reads differently from the rest of + the loop. This is the *only* thing `measure_number` changes — pure color + selection; the pulse envelope still comes from the driver/BeatGrid. + +### 7. Cross-repo: loopjefe LV2 — new `measure_number` output + monitored outputs + +In `loopjefe-lv2` (**both** `loopjefe/` and `loopjefe-2x2/` bundles — independent +copies): +- **New `measure_number` OUTPUT control port**: `lv2:ControlPort, lv2:OutputPort`, + `lv2:integer`, min 0, default 0 — the current measure index *within the loop* + (0 = the loop's own downbeat/first measure). Add to the port enum, `connect_port` + case, and write it each `run()` from the loop's phase math (the plugin already + computes loop bar/phase via `phaseMapAbsBeats`/`phaseMapPhase01` in + `transport.h`). Bump the port count and `lv2:index`es; update the `.ttl` in both + bundles. +- **Monitor both outputs**: add `state` **and** `measure_number` to the modgui's + `modgui:monitoredOutputs` so mod-host monitors them and emits `output_set` + (mod-ui auto-sends `monitor_output` for `extinfo['monitoredOutputs']` on plugin + add). Without this, pi-Stomp never receives either. Confirm the modgui.ttl block. +- Rebuild/install both bundles; verify with lilv/`make validate`. +- Update `README.md`'s port table (already stale — still lists the old 5-state + enum) to the current 9-state `state` + `measure_number`. + +## Tests + +- `tests/test_ws_protocol.py`: `output_set` parses to `OutputSetMessage` (happy + + edge). +- `tests/test_websocket_bridge.py` (or extend): interesting-set membership — a + subscribed `output_set` survives, a non-subscribed one is dropped; empty set + drops all (regression: today's behavior). +- New `tests/test_loopjefe_behavior.py`: state→color/style map for all 9 states — + Empty→`(None, SOLID)`, Stopped→`(grey, SOLID)`, active states→`(color, + METRONOME)`; `measure_number == 0` returns the distinct downbeat-variant color, + non-zero returns the base color. (Brightness/pulse is the driver's job — tested + separately.) +- `tests/test_beatsync.py` (extend): `TickState.beat_phase` advances 0→1 across a + beat and resets on crossing; `is_bar_start` on the downbeat. +- Driver test (extend `test_modhandler` or new): `METRONOME` render scales + brightness by `beat_phase` (bright at phase 0, dim near 1), brightest on + `is_bar_start`; `SOLID` render is steady; unanchored METRONOME → steady color. +- `tests/test_handle_footswitch_longpress.py` / `test_footswitch.py`: momentary + short-press emits `127` every press (no toggle) when + `fs.behavior.momentary`; unaffected footswitches still toggle. +- Full suite: `uv run pytest`; `pyright` zero + `ruff` clean (per CLAUDE.md). + +## Verification (end-to-end) + +1. Unit + type/lint gates green (`uv run pytest`, pyright, ruff). +2. **Emulator / device**: load a pedalboard with a loopjefe instance, MIDI-learn a + footswitch's short-press CC → `advance`, long-press CC → `reset`; set transport + rolling with a known BPM. + - Press short repeatedly → `state` advances (0→1→2→…); the footswitch LED shows + the mapped color and **pulses on the beat** (brightest on downbeat); Stopped = + steady grey; Empty = off. + - Long-press → `reset` clears the track → LED off. + - Confirm only the subscribed `output_set /graph/ state` flows (others + still dropped) — check WS debug logs for volume. +3. Confirm FS4 global metronome still flashes unchanged (single beat tick shared). + +## Open questions / future + +- **Tap-tempo footgun (document, not code):** the plugin aborts an in-progress take + if `transport_bpm` diverges from the sampled `capture_bpm` mid-record. Hitting + FS4 tap-tempo *while recording* kills that loop. Set tempo before recording. +- **Possible unification** of `_drive_metronome` into `_drive_footswitch_leds` + later (FS4 as an implicit taptempo behavior). diff --git a/modalapi/footswitch_behavior.py b/modalapi/footswitch_behavior.py new file mode 100644 index 000000000..3a33d1103 --- /dev/null +++ b/modalapi/footswitch_behavior.py @@ -0,0 +1,81 @@ +"""FootswitchBehavior protocol and default implementation.""" + +from __future__ import annotations + +from collections.abc import Iterable +from enum import Enum, auto +from typing import TYPE_CHECKING, Protocol +from pistomp.category import get_category_color + +if TYPE_CHECKING: + from modalapi.plugin import Plugin + from pistomp.beatsync import TickState + from pistomp.footswitch import Footswitch + + +class LedDisplayStyle(Enum): + SOLID = auto() + METRONOME = auto() + + +class FootswitchBehavior(Protocol): + """Defines how a footswitch behaves when bound to a plugin.""" + + @property + def momentary(self) -> bool: ... + + def output_subscriptions(self) -> Iterable[str]: + """Return the symbols of plugin outputs that this footswitch should subscribe to.""" + ... + + def on_output(self, symbol: str, value: float) -> None: + """Called when a subscribed output changes. The footswitch can use this to update its state.""" + ... + + def led_color(self, beat: TickState) -> tuple[int, int, int] | None: + """Return the RGB color of the footswitch LED, or None to turn it off.""" + ... + + def led_style(self, beat: TickState) -> LedDisplayStyle: ... + + +class DefaultFootswitchBehavior: + """Built-in behavior: toggle semantics, category color, no WS subscriptions.""" + + def __init__(self, fs: Footswitch) -> None: + self._fs = fs + + @property + def momentary(self) -> bool: + return False + + def output_subscriptions(self) -> Iterable[str]: + return () + + def on_output(self, symbol: str, value: float) -> None: + pass + + def led_color(self, beat: TickState) -> tuple[int, int, int] | None: + if not self._fs.toggled: + return None + if self._fs.category is not None: + return get_category_color(self._fs.category) + return (255, 255, 255) + + def led_style(self, beat: TickState) -> LedDisplayStyle: + return LedDisplayStyle.SOLID + + +def attach_footswitch_behavior(fs: Footswitch, plugin: Plugin) -> None: + """Attach the plugin's footswitch behavior to `fs`, or a DefaultFootswitchBehavior + if the plugin has no `footswitch_behavior_fn` or it returns None. + + Called from every bind site (ControllerManager.bind at pedalboard load, and + Handler._apply_midi_binding on live MIDI-learn) so the 'every footswitch + always has a behavior' invariant holds regardless of how the binding arose.""" + fn = plugin.customization.footswitch_behavior_fn + if fn is not None: + b = fn(plugin) + fs.behavior = b if b is not None else DefaultFootswitchBehavior(fs) + else: + fs.behavior = DefaultFootswitchBehavior(fs) diff --git a/modalapi/mod.py b/modalapi/mod.py index d5651f07f..92d5ff352 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -519,6 +519,26 @@ def poll_controls(self): if self.universal_encoder_mode is not UniversalEncoderMode.LOADING: self.hardware.poll_controls() self._tick_chords() + self._drive_footswitch_leds() + + def _drive_footswitch_leds(self) -> None: + """v1 has no beat_grid and no pixel (mono LCD) — just render the GPIO + LED from each footswitch's behavior. SOLID only; no metronome style.""" + if self.hardware is None: + return + from pistomp.beatsync import TickState + beat = TickState(is_anchored=False, is_flashing=False, is_bar_start=False, + bpm=0.0, bpb=0.0, beat_phase=0.0) + for fs in self.hardware.footswitches: + b = fs.behavior + if b is None: + continue + color = b.led_color(beat) + if fs.led is not None: + if color is None: + fs.led.off() + else: + fs.led.on() def poll_wifi(self): self.wifi_manager.poll() diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 1c3940082..a7a2c056d 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -42,6 +42,7 @@ from plugins.customization import lookup as plugin_lookup import modalapi.external_midi as ExternalMidi from modalapi.external_midi import EXTERNAL_INSTANCE_ID +from modalapi.footswitch_behavior import LedDisplayStyle from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute from pistomp.lcd320x240 import Lcd @@ -53,6 +54,7 @@ parse_message, LoadingEndMessage, LoadingStartMessage, + OutputSetMessage, PedalSnapshotMessage, PluginBypassMessage, TransportMessage, @@ -72,7 +74,7 @@ from pistomp.encoder_controller import EncoderController from pistomp.footswitch import Footswitch from pistomp.footswitch_chords import FootswitchChords -from pistomp.beatsync import BeatGrid +from pistomp.beatsync import BeatGrid, TickState from pistomp.input.event import ( AnalogEvent, ControllerEvent, @@ -334,42 +336,115 @@ def poll_controls(self): if self.hardware: self.hardware.poll_controls() self._tick_chords() + # 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() - self._drive_metronome() - def _drive_metronome(self): - fs = self._taptempo_footswitch() - if fs is None: + 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: + if self.hardware is None: + return + if beat is None: + beat = self.beat_grid.tick(_now_us()) + taptempo_fs = self._taptempo_footswitch() + for fs in self.hardware.footswitches: + b = fs.behavior + if b is None: + continue + # The taptempo footswitch is driven by the metronome (transport- + # anchored beat grid, or taptempo blink when unanchored), not by + # its default behavior. All other footswitches use their behavior. + if fs is taptempo_fs: + self._render_taptempo_led(fs, beat) + else: + self._render_behavior_led(fs, b, beat) + + def _render_taptempo_led(self, fs: Footswitch, beat: TickState) -> None: + """The taptempo footswitch flashes from whichever beat source is active: + transport-anchored beat grid, or taptempo.anchor + bpm when unanchored.""" + if beat.is_anchored: + if beat.is_flashing: + rgb = _METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB + self._write_led(fs, rgb) + else: + self._write_led_off(fs) return - state = self.beat_grid.tick(_now_us()) - if not state.is_anchored: - # Unanchored: leave the LED/pixel alone so Footswitch.set_led's - # own taptempo blink (or normal toggle state) isn't clobbered - # every 20ms tick. + # Unanchored: if taptempo is enabled with a bpm, blink from the taptempo + # phase (on for the first ~100ms of each beat period). This replaces the + # old gpiozero hardware blink() with a 10ms-driver-computed on/off. + if fs.taptempo is not None and fs.taptempo.is_enabled() and 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 < 0.1: # 100ms on-window + self._write_led(fs, _METRONOME_BEAT_RGB) + else: + self._write_led_off(fs) return - if state.is_flashing: + # Taptempo disabled or no bpm yet: fall through to the default behavior. + b = fs.behavior + if b is not None: + color = b.led_color(beat) + if color is None: + self._write_led_off(fs) + else: + self._write_led(fs, color) + + @staticmethod + def _render_behavior_led(fs: Footswitch, b, beat: TickState) -> None: + color = b.led_color(beat) + style = b.led_style(beat) + if color is None: if fs.pixel is not None: - rgb = _METRONOME_DOWNBEAT_RGB if state.is_bar_start else _METRONOME_BEAT_RGB - fs.pixel.set_color(rgb) + fs.pixel.set_enable(False) + if fs.led is not None: + fs.led.off() + return + if style == LedDisplayStyle.METRONOME and beat.is_anchored: + phase = beat.beat_phase + brightness = 1.0 - (phase * 0.7) + if beat.is_bar_start: + brightness = 1.0 + scaled = tuple(int(c * brightness) for c in color) + if fs.pixel is not None: + fs.pixel.set_color(scaled) fs.pixel.set_enable(True) if fs.led is not None: fs.led.on() else: if fs.pixel is not None: - fs.pixel.set_enable(False) + fs.pixel.set_color(color) + fs.pixel.set_enable(True) if fs.led is not None: - fs.led.off() + fs.led.on() - 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 + @staticmethod + def _write_led(fs: Footswitch, color: tuple[int, int, int]) -> None: + 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() + + @staticmethod + def _write_led_off(fs: Footswitch) -> None: + if fs.pixel is not None: + fs.pixel.set_enable(False) + if fs.led is not None: + fs.led.off() def poll_wifi(self): self.wifi_manager.poll() @@ -671,6 +746,12 @@ def _handle_ws_message(self, msg: WebSocketMessage): # MIDI learn in mod-ui assigned a hardware control to a parameter. self._apply_midi_binding(msg.instance, msg.symbol, msg.binding) + elif isinstance(msg, OutputSetMessage): + if self._current is not None: + for fs in self.hardware.footswitches: + if fs.behavior is not None and fs.parameter is not None and fs.parameter.instance_id == msg.instance: + fs.behavior.on_output(msg.symbol, msg.value) + def _handle_dynamic_plugin_add(self, msg: AddPluginMessage) -> None: """Handle an `add` WS message for a plugin not yet in the pedalboard model.""" assert self._current is not None @@ -930,6 +1011,21 @@ 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 footswitch behaviors.""" + if self.hardware is None: + self.ws_bridge.set_interesting_outputs(frozenset()) + return + keys: set[str] = set() + for fs in self.hardware.footswitches: + b = fs.behavior + if b is None or fs.parameter is None: + continue + for sym in b.output_subscriptions(): + keys.add(f"{fs.parameter.instance_id}/{sym}") + self.ws_bridge.set_interesting_outputs(frozenset(keys)) def _redraw_after_binding(self, controller, is_footswitch): if is_footswitch: @@ -938,6 +1034,11 @@ def _redraw_after_binding(self, controller, is_footswitch): else: self.lcd.draw_analog_assignments(self.current.analog_controllers) + def _on_footswitch_binding_changed(self) -> None: + # A live MIDI-learn attached a behavior to a footswitch; recompute the + # WS output_set subscription set so the behavior receives on_output. + self._update_interesting_outputs() + def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None: logging.info("Pedalboard change") self.lcd.draw_info_message("Loading...") diff --git a/modalapi/plugin_customization.py b/modalapi/plugin_customization.py index da8c98bc4..0d3a0a618 100644 --- a/modalapi/plugin_customization.py +++ b/modalapi/plugin_customization.py @@ -9,6 +9,7 @@ from common.color import RectBorder if TYPE_CHECKING: + from modalapi.footswitch_behavior import FootswitchBehavior from modalapi.plugin import Plugin from plugins.base import PluginPanel @@ -37,6 +38,9 @@ class PluginCustomization: tile_active_color: tuple[int, int, int] | None = None tile_border: RectBorder | None = None extra_data: PluginExtraData | None = None + footswitch_behavior_fn: Callable[[Plugin], FootswitchBehavior | None] | None = field( + default=None, compare=False, hash=False + ) class Customizer(Protocol): diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index 9902f7bb1..3b21a2221 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -45,7 +45,7 @@ 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 @@ -55,6 +55,12 @@ 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() # Metrics self.messages_sent = 0 @@ -205,7 +211,20 @@ 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. + interesting = self._interesting + if interesting: + parts = message.split(" ", 3) + if len(parts) >= 3: + path = parts[1] + symbol = parts[2] + inst = path.removeprefix("/graph/") + key = f"{inst}/{symbol}" + if key in interesting: + self.received_queue.put(message) + self.messages_received += 1 + logging.debug(f"Received subscribed output_set: {message[:100]}") + continue self.received_queue.put(message) self.messages_received += 1 logging.debug(f"Received message from server: {message[:100]}") @@ -214,6 +233,12 @@ 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).""" + self._interesting = keys + def _get_write_buffer_size(self, ws) -> int: """Return bytes waiting in the TCP write buffer, or 0 if unavailable.""" try: @@ -286,6 +311,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(), diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py index 8f6546539..c886d4e3e 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -149,6 +149,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 ...).""" @@ -188,6 +197,7 @@ class UnknownMessage: ConnectMessage, DisconnectMessage, ParamSetMessage, + OutputSetMessage, MidiMapMessage, UnknownMessage, ] @@ -292,6 +302,12 @@ def parse_message(raw_message: str) -> WebSocketMessage: symbol, value_str = rest.split(" ", 1) return ParamSetMessage(instance=instance, 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 = rest.split(" ")[:3] diff --git a/pistomp/beatsync.py b/pistomp/beatsync.py index 2f5d95a5f..03ebb9538 100644 --- a/pistomp/beatsync.py +++ b/pistomp/beatsync.py @@ -29,6 +29,7 @@ class TickState: is_bar_start: bool bpm: float bpb: float + beat_phase: float = 0.0 # normalized [0, 1) within the current beat class BeatGrid: @@ -78,6 +79,7 @@ def tick(self, now_us: int) -> TickState: delta_us = now_us - self._anchor_t_us delta_beats = delta_us * self._bpm / 60_000_000.0 current_beat_idx = self._anchor_beat_idx + int(delta_beats) + beat_phase = delta_beats - int(delta_beats) # fractional part [0, 1) if current_beat_idx > self._last_beat_idx: self._last_beat_idx = current_beat_idx @@ -95,4 +97,5 @@ def tick(self, now_us: int) -> TickState: is_bar_start=is_flashing and self._last_crossing_was_bar_start, bpm=self._bpm, bpb=self._bpb, + beat_phase=beat_phase, ) diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index ceb156989..b65b42ea5 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -21,6 +21,7 @@ import common.token as Token from common.parameter import Parameter, TTL_PROPERTIES, TTL_INTEGER from modalapi.external_midi import EXTERNAL_INSTANCE_ID +from modalapi.footswitch_behavior import attach_footswitch_behavior from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.controller import AnalogDisplayInfo from pistomp.current import Current @@ -93,6 +94,7 @@ def _bind_plugin_parameters(self, current) -> list: plugin.has_footswitch = True footswitch_plugins.append(plugin) controller.set_category(plugin.category) + attach_footswitch_behavior(controller, plugin) else: key = "%s:%s" % (plugin.instance_id, param.name) display_info = controller.get_display_info() diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index ca7d4bb49..a83defeaf 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -15,6 +15,7 @@ import logging import sys +from typing import TYPE_CHECKING from typing_extensions import override import common.token as Token @@ -24,6 +25,9 @@ import pistomp.switchstate as switchstate from pistomp.input.event import SwitchEvent, SwitchEventKind +if TYPE_CHECKING: + from modalapi.footswitch_behavior import FootswitchBehavior + class Footswitch(controller.Controller): @@ -45,6 +49,7 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.longpress_midi_CC = None self.disabled = False self.taptempo = taptempo + self.behavior: FootswitchBehavior | None = None if adc_input and gpio_input: logging.error("Switch cannot be specified with both %s and %s", (Token.ADC_INPUT, Token.GPIO_INPUT)) @@ -113,24 +118,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 @@ -192,4 +186,10 @@ def clear_pedalboard_info(self): self.preset_callback = None self.preset_callback_arg = None self.parameter = None + # Reset to the default behavior; ControllerManager.bind overlays a + # plugin-specific one if this switch binds to a plugin parameter. + # Preset/relay/unbound switches keep the default so the per-tick LED + # driver still lights their pixel. + from modalapi.footswitch_behavior import DefaultFootswitchBehavior + self.behavior = DefaultFootswitchBehavior(self) self.clear_relays() diff --git a/pistomp/handler.py b/pistomp/handler.py index fff849952..b30cda43e 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -21,6 +21,7 @@ from rtmidi.midiconstants import CONTROL_CHANGE +from modalapi.footswitch_behavior import attach_footswitch_behavior from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.current import Current from pistomp.encoder_controller import EncoderController @@ -98,6 +99,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() @@ -165,9 +173,12 @@ def _handle_footswitch(self, fs: "Footswitch", kind: SwitchEventKind, timestamp: fs.preset_callback() return True if fs.midi_CC is not None: - fs.toggled = not fs.toggled - fs.set_led(fs.toggled) - self._emit_midi(fs, 127 if fs.toggled else 0) + if fs.behavior is not None and fs.behavior.momentary: + self._emit_midi(fs, 127) + else: + fs.toggled = not fs.toggled + fs.set_led(fs.toggled) + self._emit_midi(fs, 127 if fs.toggled else 0) if fs.parameter is not None: fs.parameter.value = not fs.toggled # FIXME: assumes mapped parameter is :bypass self.update_lcd_fs(footswitch=fs) @@ -266,6 +277,8 @@ def _apply_midi_binding(self, instance, symbol, binding): param.binding = binding is_footswitch = self._bind_controller_to_param(plugin, param, controller) self._redraw_after_binding(controller, is_footswitch) + if is_footswitch: + self._on_footswitch_binding_changed() def _bind_controller_to_param(self, plugin, param, controller) -> bool: # Wire a hardware controller to a plugin parameter. Returns True if the @@ -280,6 +293,7 @@ def _bind_controller_to_param(self, plugin, param, controller) -> bool: # TODO sort this list so selection orders correctly (sort on midi_CC?) plugin.has_footswitch = True controller.set_category(plugin.category) + attach_footswitch_behavior(controller, plugin) return True elif isinstance(controller, (AnalogMidiControl, EncoderController)): key = "%s:%s" % (plugin.instance_id, param.name) @@ -288,6 +302,12 @@ def _bind_controller_to_param(self, plugin, param, controller) -> bool: 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. Base no-op for v1 (Mod).""" + return + def _redraw_after_binding(self, controller, is_footswitch): # Refresh the LCD after a learned binding. Subclasses redraw at their # own granularity. diff --git a/pistomp/hardware.py b/pistomp/hardware.py index 4db79a9ff..72e11c0fc 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -28,6 +28,7 @@ from abc import ABC, abstractmethod from rtmidi import MidiOut from modalapi.external_midi import ExternalMidiManager, EXTERNAL_INSTANCE_ID +from modalapi.footswitch_behavior import DefaultFootswitchBehavior from pistomp.input.sink import InputSink from pistomp.controller import Controller, RoutingInfo, RoutingDestination import pistomp.relay as Relay @@ -264,6 +265,12 @@ def create_footswitches(self, cfg): (gpio_input, midi_channel, midi_cc)) assert fs is not None, "No footswitch created for config: %s" % f + # Every footswitch always has a behavior. Plugin-bound switches get + # a plugin-specific one via ControllerManager.bind; preset/relay/ + # unbound switches keep this default (toggle + category color) so + # the per-tick LED driver lights their pixel too. + if fs.behavior is None: + fs.behavior = DefaultFootswitchBehavior(fs) self.footswitches.append(fs) idx += 1 diff --git a/plugins/__init__.py b/plugins/__init__.py index ab63af1e9..c3b9c65d0 100644 --- a/plugins/__init__.py +++ b/plugins/__init__.py @@ -49,6 +49,7 @@ import plugins.system_compressor # noqa: F401 import plugins.multiband_menu # noqa: F401 # MultibandWindow base import plugins.layouts # noqa: F401 # Layout components +import plugins.loopjefe # noqa: F401 # LoopJefe footswitch behavior __all__ = [ "PluginCustomization", diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py new file mode 100644 index 000000000..7922d6711 --- /dev/null +++ b/plugins/loopjefe/__init__.py @@ -0,0 +1,88 @@ +"""LoopJefe multitrack looper plugin customization. + +Registers footswitch behavior for loopjefe-lv2: momentary short-press +semantics, output_set subscriptions for state and measure_number, and +per-tick LED rendering (state color + beat pulse). +""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import TYPE_CHECKING + +from modalapi.footswitch_behavior import LedDisplayStyle +from modalapi.plugin import Plugin +from plugins.customization import PluginCustomization, register + +if TYPE_CHECKING: + from pistomp.beatsync import TickState + + +LOOPJEFE_URIS = ( + "http://treefallsound.com/plugins/loopjefe", + "http://treefallsound.com/plugins/loopjefe-2x2", +) + +_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 + 6: (0, 80, 255), # Overdub Arm + 7: (255, 140, 0), # Overdub + 8: (0, 80, 255), # Overdub Close +} + +_DOWNBEAT_TINT = 60 # added to each channel for the loop-downbeat variant + + +def _brighten(c: tuple[int, int, int]) -> tuple[int, int, int]: + return (min(255, c[0] + _DOWNBEAT_TINT), min(255, c[1] + _DOWNBEAT_TINT), min(255, c[2] + _DOWNBEAT_TINT)) + + +class LoopjefeBehavior: + def __init__(self, plugin: Plugin) -> None: + self._instance_id = plugin.instance_id + self._state: int = 0 + self._measure_number: int = 0 + + @property + def momentary(self) -> bool: + return True + + def output_subscriptions(self) -> Iterable[str]: + return ("state", "measure_number") + + def on_output(self, symbol: str, value: float) -> None: + if symbol == "state": + self._state = int(value) + elif symbol == "measure_number": + self._measure_number = int(value) + + def led_color(self, beat: TickState) -> tuple[int, int, int] | None: + if self._state == 0: + return None + if self._state == 5: + return (80, 80, 80) + base = _STATE_COLORS.get(self._state, (80, 80, 80)) + if self._measure_number == 0: + return _brighten(base) + return base + + def led_style(self, beat: TickState) -> LedDisplayStyle: + if self._state == 0 or self._state == 5: + return LedDisplayStyle.SOLID + return LedDisplayStyle.METRONOME + + +def make_loopjefe_behavior(plugin: Plugin) -> LoopjefeBehavior: + return LoopjefeBehavior(plugin) + + +register( + *LOOPJEFE_URIS, + customization=PluginCustomization( + display_name="LoopJefe", + footswitch_behavior_fn=make_loopjefe_behavior, + ), +) diff --git a/tests/conftest.py b/tests/conftest.py index 33e7f3e39..dd094493f 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/input_router/test_handle_footswitch_longpress.py b/tests/input_router/test_handle_footswitch_longpress.py index 4215b8af8..81cae1346 100644 --- a/tests/input_router/test_handle_footswitch_longpress.py +++ b/tests/input_router/test_handle_footswitch_longpress.py @@ -79,3 +79,69 @@ def test_no_longpress_midi_cc_falls_through_to_chord(self): handler._handle_footswitch(fs, SwitchEventKind.LONGPRESS, timestamp=1.0) hw.midiout.send_message.assert_not_called() + + +class _StubBehavior: + """Minimal behavior stub for momentary-press tests.""" + + def __init__(self, momentary: bool) -> None: + self.momentary = momentary + + def output_subscriptions(self): + return () + + def on_output(self, symbol: str, value: float) -> None: + pass + + def led_color(self, beat): + return None + + def led_style(self, beat): + from modalapi.footswitch_behavior import LedDisplayStyle + return LedDisplayStyle.SOLID + + +class TestMomentaryShortPress: + """A footswitch whose behavior.momentary is True emits 127 every press + (rising-edge trigger semantics) — no toggled flip. Otherwise the existing + toggle behavior (127/0 alternating) is preserved.""" + + def test_momentary_emits_127_every_press(self): + handler, hw = _make_handler() + fs = _make_footswitch(midi_CC=10) + fs.behavior = _StubBehavior(momentary=True) + + for _ in range(3): + handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) + + messages = [c.args[0] for c in hw.midiout.send_message.call_args_list] + assert len(messages) == 3 + assert all(m[2] == 127 for m in messages) + assert fs.toggled is False # never flipped + + def test_non_momentary_toggles_as_before(self): + handler, hw = _make_handler() + fs = _make_footswitch(midi_CC=10) + fs.behavior = _StubBehavior(momentary=False) + + handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) + handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=2.0) + + messages = [c.args[0] for c in hw.midiout.send_message.call_args_list] + assert len(messages) == 2 + assert messages[0][2] == 127 # first press → toggled True → 127 + assert messages[1][2] == 0 # second press → toggled False → 0 + assert fs.toggled is False + + def test_no_behavior_toggles_as_before(self): + """Regression guard: a footswitch with behavior=None (e.g. a preset + switch that never went through ControllerManager.bind) still toggles.""" + handler, hw = _make_handler() + fs = _make_footswitch(midi_CC=10) + fs.behavior = None + + handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) + + message = hw.midiout.send_message.call_args.args[0] + assert message[2] == 127 + assert fs.toggled is True diff --git a/tests/test_beatsync.py b/tests/test_beatsync.py index 35790b5b2..0210fced9 100644 --- a/tests/test_beatsync.py +++ b/tests/test_beatsync.py @@ -172,3 +172,40 @@ def test_tick_state_is_immutable(self): except Exception: return raise AssertionError("TickState should be frozen") + + +class TestBeatPhase: + """beat_phase is the normalized [0, 1) within-beat position the + footswitch-LED driver uses to scale brightness.""" + + def test_phase_zero_at_beat_boundary(self): + g = BeatGrid() + g.on_anchor(_anchor(bar=0, 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(bar=0, 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(bar=0, 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(bar=0, 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_loopjefe_behavior.py b/tests/test_loopjefe_behavior.py new file mode 100644 index 000000000..3991ac240 --- /dev/null +++ b/tests/test_loopjefe_behavior.py @@ -0,0 +1,132 @@ +"""LoopJefe footswitch behavior — state→color/style contract + registration. + +Pins: + - The loopjefe URIs are registered (plugins/__init__.py imports plugins.loopjefe). + - make_loopjefe_behavior produces a behavior with momentary=True and the + expected output_subscriptions. + - led_color / led_style for all 9 states, including the measure_number==0 + loop-downbeat tint. + - Brightness/pulse is the driver's job — not tested here. +""" + +from __future__ import annotations + +import pytest + +from modalapi.footswitch_behavior import LedDisplayStyle +from modalapi.plugin import Plugin +from pistomp.beatsync import TickState +from plugins import lookup, registered_uris +from plugins.loopjefe import LOOPJEFE_URIS, make_loopjefe_behavior + + +def _tick(beat_phase: float = 0.0, is_bar_start: bool = False) -> TickState: + return TickState( + is_anchored=True, + is_flashing=is_bar_start, + is_bar_start=is_bar_start, + bpm=120.0, + bpb=4.0, + beat_phase=beat_phase, + ) + + +def _make_plugin(uri: str = LOOPJEFE_URIS[0]) -> Plugin: + customization = lookup(uri) + # Plugin() requires a parameters dict; loopjefe behavior only reads instance_id. + return Plugin("loopjefe", {}, {}, "Looper", uri=uri, customization=customization) + + +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_customization_with_behavior_fn(self): + for uri in LOOPJEFE_URIS: + cust = lookup(uri) + assert cust.footswitch_behavior_fn is make_loopjefe_behavior, ( + f"lookup({uri!r}) did not return the loopjefe customization" + ) + + +class TestBehaviorContract: + def test_momentary_short_press(self): + b = make_loopjefe_behavior(_make_plugin()) + assert b.momentary is True + + def test_output_subscriptions_state_and_measure_number(self): + b = make_loopjefe_behavior(_make_plugin()) + assert set(b.output_subscriptions()) == {"state", "measure_number"} + + def test_on_output_caches_state(self): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", 2.0) + b.on_output("measure_number", 1.0) # non-downbeat so base color is returned + assert b.led_color(_tick()) == (255, 0, 0) # Recording + + def test_on_output_caches_measure_number(self): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", 1.0) # Record Arm → blue + b.on_output("measure_number", 0.0) + # measure_number == 0 → downbeat tint (brighter) + assert b.led_color(_tick()) != (0, 80, 255) + b.on_output("measure_number", 3.0) + assert b.led_color(_tick()) == (0, 80, 255) + + +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): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", float(state)) + b.on_output("measure_number", 1.0) # not the loop downbeat + assert b.led_color(_tick()) == 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): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", float(state)) + assert b.led_style(_tick()) == expected_style + + +class TestLoopDownbeatTint: + def test_measure_zero_returns_distinct_color(self): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", 2.0) # Recording → red + b.on_output("measure_number", 0.0) + downbeat = b.led_color(_tick()) + b.on_output("measure_number", 2.0) + normal = b.led_color(_tick()) + 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): + b = make_loopjefe_behavior(_make_plugin()) + b.on_output("state", 0.0) + b.on_output("measure_number", 0.0) + assert b.led_color(_tick()) is None \ No newline at end of file diff --git a/tests/test_websocket_bridge.py b/tests/test_websocket_bridge.py index ff10395fa..3f7d88890 100644 --- a/tests/test_websocket_bridge.py +++ b/tests/test_websocket_bridge.py @@ -155,6 +155,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 diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py index f8352b970..ff60d15e7 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -9,6 +9,7 @@ LoadingEndMessage, LoadingStartMessage, MidiMapMessage, + OutputSetMessage, PedalSnapshotMessage, ParamSetMessage, PluginBypassMessage, @@ -389,3 +390,33 @@ def test_malformed_pedal_snapshot_non_int(): def test_empty_string(): msg = parse_message("") 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) diff --git a/tests/v3/test_footswitch_led_driver.py b/tests/v3/test_footswitch_led_driver.py new file mode 100644 index 000000000..0183bb642 --- /dev/null +++ b/tests/v3/test_footswitch_led_driver.py @@ -0,0 +1,191 @@ +"""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 behavior query — no separate set_led path. + +Covers: + - SOLID: shows the behavior's color steadily (or off when color is None). + - METRONOME: scales brightness by beat_phase (bright at 0, dim toward 1). + - Unanchored METRONOME: steady color (no 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. + - Taptempo footswitch: transport-anchored → beat-synced flash; taptempo-only + → blink from taptempo.anchor + bpm; taptempo disabled → default behavior. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from modalapi.footswitch_behavior 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, + ) + + +class _StubBehavior: + """Minimal behavior stub for driver tests — returns a fixed color/style.""" + + def __init__(self, color, style: LedDisplayStyle = LedDisplayStyle.SOLID, + momentary: bool = False) -> None: + self._color = color + self._style = style + self.momentary = momentary + + def output_subscriptions(self): + return () + + def on_output(self, symbol: str, value: float) -> None: + pass + + def led_color(self, beat: TickState): + return self._color + + def led_style(self, beat: TickState) -> LedDisplayStyle: + return self._style + + +def _drive(handler: Modhandler, beat: TickState) -> None: + """Invoke the driver directly with a fabricated beat state.""" + handler._drive_footswitch_leds(beat) + + +def _fs_with_behavior(v3_system: SystemFixture, behavior) -> Footswitch: + fs = v3_system.hw.footswitches[0] + fs.behavior = behavior + fs.pixel = MagicMock() + fs.led = MagicMock() + return fs + + +class TestSolidStyle: + def test_solid_shows_color_steadily(self, v3_system: SystemFixture): + fs = _fs_with_behavior(v3_system, _StubBehavior((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_behavior(v3_system, _StubBehavior((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_behavior(v3_system, _StubBehavior(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_bright_at_phase_zero(self, v3_system: SystemFixture): + fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + _drive(v3_system.handler, _beat(beat_phase=0.0)) + # phase 0 → brightness 1.0 → unscaled color + fs.pixel.set_color.assert_called_once_with((100, 100, 100)) + + def test_metronome_dim_near_phase_one(self, v3_system: SystemFixture): + fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + _drive(v3_system.handler, _beat(beat_phase=0.9)) + scaled = fs.pixel.set_color.call_args.args[0] + # phase 0.9 → brightness 1.0 - 0.9*0.7 = 0.37 → 100*0.37 = 37 + assert scaled == (37, 37, 37) + assert all(c < 100 for c in scaled) + + def test_metronome_brightest_on_bar_start(self, v3_system: SystemFixture): + fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + _drive(v3_system.handler, _beat(beat_phase=0.5, is_bar_start=True)) + # bar start forces brightness 1.0 even though phase is mid-beat + 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_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + _drive(v3_system.handler, _beat(beat_phase=0.9, is_anchored=False)) + # No pulse when unanchored — full color + fs.pixel.set_color.assert_called_once_with((100, 100, 100)) + + +class TestNoBehavior: + def test_footswitch_without_behavior_is_skipped(self, v3_system: SystemFixture): + fs = v3_system.hw.footswitches[0] + fs.behavior = None + fs.pixel = MagicMock() + _drive(v3_system.handler, _beat()) + fs.pixel.set_enable.assert_not_called() + fs.pixel.set_color.assert_not_called() + + +class TestPixelAndLedSameSource: + """Both fs.pixel and fs.led are written from the same behavior 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_behavior(v3_system, _StubBehavior((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_behavior(v3_system, _StubBehavior(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_behavior(v3_system, _StubBehavior((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_behavior(v3_system, _StubBehavior((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() \ No newline at end of file diff --git a/tests/v3/test_footswitch_presets.py b/tests/v3/test_footswitch_presets.py index 72ab7a57d..95a40cbcf 100644 --- a/tests/v3/test_footswitch_presets.py +++ b/tests/v3/test_footswitch_presets.py @@ -10,15 +10,15 @@ ControllerManager.bind() can 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 Parameter from tests.types import SystemFixture @@ -116,13 +116,21 @@ class TestPresetFootswitchIndicator: def test_active_snapshot_footswitch_drives_physical_led(self, v3_system: SystemFixture): """A press never touches fs.toggled for preset footswitches (handler.py returns early on the preset_callback branch), so the LCD redraw path - is the only place that can also light the physical LED/pixel.""" + 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=handler.preset_set_and_change, callback_arg=0) fs1.add_preset(callback=handler.preset_set_and_change, callback_arg=1) handler.current.preset_index = 1 @@ -130,8 +138,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_midi_learn.py b/tests/v3/test_midi_learn.py index a21cad2e7..8a093de6c 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -109,3 +109,112 @@ def test_v3_midi_learn_unknown_instance_is_ignored(v3_system: SystemFixture, mak assert fs0.parameter is None assert plugin.has_footswitch is False + + +def _make_loopjefe_plugin_with_advance(make_parameter, instance_id="loopjefe"): + """Build a loopjefe plugin with an `advance` parameter, using the loopjefe + customization directly (so the test doesn't depend on plugins/__init__.py + importing plugins.loopjefe).""" + from modalapi.plugin import Plugin + from modalapi.plugin_customization import PluginCustomization + from plugins.loopjefe import make_loopjefe_behavior + + advance = make_parameter("advance", instance_id, value=0.0) + customization = PluginCustomization( + display_name="LoopJefe", + footswitch_behavior_fn=make_loopjefe_behavior, + ) + return Plugin(instance_id, {"advance": advance}, {}, "Looper", + uri="http://treefallsound.com/plugins/loopjefe", + customization=customization) + + +class TestMidiLearnAttachesBehavior: + """Regression: the live MIDI-learn path (Handler._apply_midi_binding → + _bind_controller_to_param) must attach a FootswitchBehavior when the plugin + has a footswitch_behavior_fn, and must refresh the WS output_set + subscription set. Without this, a footswitch MIDI-learned to loopjefe's + `advance` after pedalboard load keeps DefaultFootswitchBehavior (toggle, + no output_set subscription) — the LED never lights and short-press toggles + 127/0 instead of sending a momentary 127.""" + + def test_midi_learn_attaches_loopjefe_behavior(self, v3_system: SystemFixture, make_parameter): + from plugins.loopjefe import LoopjefeBehavior + + 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["advance"] + assert isinstance(fs0.behavior, LoopjefeBehavior), ( + "midi_map must attach the plugin's footswitch_behavior_fn — " + "without it the LED never lights and short-press toggles instead of momentary" + ) + + def test_midi_learn_refreshes_interesting_outputs(self, v3_system: SystemFixture, make_parameter): + """Regression: after midi_map binds a footswitch to a plugin with + output_subscriptions, the WS bridge's interesting-set must include + those subscriptions so the behavior actually receives on_output calls.""" + 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.interesting_calls.clear() + ws_bridge.inject(f"midi_map /graph/loopjefe advance {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + + # The last set_interesting_outputs call must include the loopjefe + # output_subscriptions for the bound instance. + assert ws_bridge.interesting_calls, "midi_map must trigger _update_interesting_outputs" + last = ws_bridge.interesting_calls[-1] + assert "loopjefe/state" in last + assert "loopjefe/measure_number" in last + + def test_output_set_reaches_bound_behavior(self, v3_system: SystemFixture, make_parameter): + """End-to-end: after midi_map binds fs0 to loopjefe/advance, an + output_set for loopjefe/state must update the behavior's cached state + so the LED driver renders the right color.""" + from modalapi.footswitch_behavior import LedDisplayStyle + from pistomp.beatsync import TickState + + 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() + + # Drive a state change from mod-ui + 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() + + beat = TickState(True, False, False, 120.0, 4.0, 0.0) + assert fs0.behavior is not None + color = fs0.behavior.led_color(beat) + assert color == (255, 0, 0) # Recording → red + assert fs0.behavior.led_style(beat) == LedDisplayStyle.METRONOME diff --git a/tests/v3/test_taptempo_led.py b/tests/v3/test_taptempo_led.py new file mode 100644 index 000000000..781c647f6 --- /dev/null +++ b/tests/v3/test_taptempo_led.py @@ -0,0 +1,155 @@ +"""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 ~100ms of each + beat period, off otherwise. + - Taptempo disabled: the footswitch behaves as a default toggle. + +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() + + +class TestTransportAnchored: + """When beat_grid is anchored (beat_sync received), 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) + 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) + 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) + 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, 100ms on-window + fs.taptempo.anchor = 1000.0 # last tap at t=1000.0 + + # Now=1000.05 → 50ms into the beat → within the 100ms on-window → ON + 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) + ) + 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.enable(True) + fs.taptempo.set_bpm(120.0) # 500ms period, 100ms on-window + fs.taptempo.anchor = 1000.0 + + # Now=1000.3 → 300ms into the beat → past the 100ms 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] \ No newline at end of file From 97e5775122d018e9500efe359c827933eaf34be3 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 6 Jul 2026 20:53:15 -0400 Subject: [PATCH 7/8] Refactor --- common/parameter.py | 12 +- modalapi/footswitch_behavior.py | 81 ---------- modalapi/led_render.py | 54 +++++++ modalapi/mod.py | 29 ++-- modalapi/modhandler.py | 149 +++++++++--------- modalapi/plugin.py | 20 +++ modalapi/plugin_customization.py | 28 +++- modalapi/ws_protocol.py | 19 ++- pistomp/beatsync.py | 48 ++++-- pistomp/controller_manager.py | 2 - pistomp/footswitch.py | 11 -- pistomp/handler.py | 4 +- pistomp/hardware.py | 7 - plugins/loopjefe/__init__.py | 80 +++------- .../test_handle_footswitch_longpress.py | 40 ++--- tests/test_beatsync.py | 64 +++++--- tests/test_loopjefe_behavior.py | 120 +++++--------- tests/test_ws_protocol.py | 18 ++- tests/v3/test_footswitch_led_driver.py | 98 ++++++------ tests/v3/test_midi_learn.py | 98 +++++------- 20 files changed, 463 insertions(+), 519 deletions(-) delete mode 100644 modalapi/footswitch_behavior.py create mode 100644 modalapi/led_render.py diff --git a/common/parameter.py b/common/parameter.py index e7d493984..e3c546226 100644 --- a/common/parameter.py +++ b/common/parameter.py @@ -26,6 +26,7 @@ TTL_SCALEPOINTS = 'scalePoints' TTL_TAPTEMPO = 'tapTempo' TTL_TOGGLED = 'toggled' +TTL_TRIGGER = 'trigger' class Type(Enum): DEFAULT = 0 # No explicitly defined type (eg. linear float) @@ -34,6 +35,7 @@ class Type(Enum): LOGARITHMIC = 3 TAPTEMPO = 4 TOGGLED = 5 + TRIGGER = 6 # pprops:trigger — edge-triggered, self-clearing (momentary) class Parameter: @@ -57,7 +59,9 @@ def __init__(self, plugin_info, value: float, binding, instance_id=None): properties = util.DICT_GET(plugin_info, TTL_PROPERTIES) if properties is not None and len(properties) > 0: - if TTL_ENUMERATION in properties: + if TTL_TRIGGER in properties: + self.type = Type.TRIGGER + elif TTL_ENUMERATION in properties: self.enum_values = util.DICT_GET(plugin_info, TTL_SCALEPOINTS) self.type = Type.ENUMERATION elif TTL_INTEGER in properties: @@ -69,6 +73,12 @@ def __init__(self, plugin_info, value: float, binding, instance_id=None): elif TTL_TOGGLED in properties: 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 + def get_enum_value_list(self): ret = [] for v in self.enum_values: diff --git a/modalapi/footswitch_behavior.py b/modalapi/footswitch_behavior.py deleted file mode 100644 index 3a33d1103..000000000 --- a/modalapi/footswitch_behavior.py +++ /dev/null @@ -1,81 +0,0 @@ -"""FootswitchBehavior protocol and default implementation.""" - -from __future__ import annotations - -from collections.abc import Iterable -from enum import Enum, auto -from typing import TYPE_CHECKING, Protocol -from pistomp.category import get_category_color - -if TYPE_CHECKING: - from modalapi.plugin import Plugin - from pistomp.beatsync import TickState - from pistomp.footswitch import Footswitch - - -class LedDisplayStyle(Enum): - SOLID = auto() - METRONOME = auto() - - -class FootswitchBehavior(Protocol): - """Defines how a footswitch behaves when bound to a plugin.""" - - @property - def momentary(self) -> bool: ... - - def output_subscriptions(self) -> Iterable[str]: - """Return the symbols of plugin outputs that this footswitch should subscribe to.""" - ... - - def on_output(self, symbol: str, value: float) -> None: - """Called when a subscribed output changes. The footswitch can use this to update its state.""" - ... - - def led_color(self, beat: TickState) -> tuple[int, int, int] | None: - """Return the RGB color of the footswitch LED, or None to turn it off.""" - ... - - def led_style(self, beat: TickState) -> LedDisplayStyle: ... - - -class DefaultFootswitchBehavior: - """Built-in behavior: toggle semantics, category color, no WS subscriptions.""" - - def __init__(self, fs: Footswitch) -> None: - self._fs = fs - - @property - def momentary(self) -> bool: - return False - - def output_subscriptions(self) -> Iterable[str]: - return () - - def on_output(self, symbol: str, value: float) -> None: - pass - - def led_color(self, beat: TickState) -> tuple[int, int, int] | None: - if not self._fs.toggled: - return None - if self._fs.category is not None: - return get_category_color(self._fs.category) - return (255, 255, 255) - - def led_style(self, beat: TickState) -> LedDisplayStyle: - return LedDisplayStyle.SOLID - - -def attach_footswitch_behavior(fs: Footswitch, plugin: Plugin) -> None: - """Attach the plugin's footswitch behavior to `fs`, or a DefaultFootswitchBehavior - if the plugin has no `footswitch_behavior_fn` or it returns None. - - Called from every bind site (ControllerManager.bind at pedalboard load, and - Handler._apply_midi_binding on live MIDI-learn) so the 'every footswitch - always has a behavior' invariant holds regardless of how the binding arose.""" - fn = plugin.customization.footswitch_behavior_fn - if fn is not None: - b = fn(plugin) - fs.behavior = b if b is not None else DefaultFootswitchBehavior(fs) - else: - fs.behavior = DefaultFootswitchBehavior(fs) diff --git a/modalapi/led_render.py b/modalapi/led_render.py new file mode 100644 index 000000000..231163bab --- /dev/null +++ b/modalapi/led_render.py @@ -0,0 +1,54 @@ +# 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 per-tick brightness +envelope (pulse phase, downbeat emphasis) is applied uniformly by the +handler's single LED-writing helper, not here. +""" + +from __future__ import annotations + +from enum import Enum, auto +from typing import TYPE_CHECKING + +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 diff --git a/modalapi/mod.py b/modalapi/mod.py index 92d5ff352..5a5c42c8e 100755 --- a/modalapi/mod.py +++ b/modalapi/mod.py @@ -35,6 +35,8 @@ from pistomp.input.event import AnalogEvent, ControllerEvent, EncoderEvent, SwitchEvent, SwitchEventKind from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute +from modalapi.led_render import render_led_spec +from pistomp.category import get_category_color from typing import Optional from blend.snapshot import SnapshotManager @@ -522,24 +524,33 @@ def poll_controls(self): self._drive_footswitch_leds() def _drive_footswitch_leds(self) -> None: - """v1 has no beat_grid and no pixel (mono LCD) — just render the GPIO - LED from each footswitch's behavior. SOLID only; no metronome style.""" + """v1 has no beat_grid and no pixel (mono LCD) — just render each + footswitch's GPIO on/off state from its bound plugin's LedSpec (if + any) or the default toggle/category behavior. No metronome style.""" if self.hardware is None: return - from pistomp.beatsync import TickState - beat = TickState(is_anchored=False, is_flashing=False, is_bar_start=False, - bpm=0.0, bpb=0.0, beat_phase=0.0) for fs in self.hardware.footswitches: - b = fs.behavior - if b is None: - continue - color = b.led_color(beat) + plugin = self._bound_plugin(fs) + if plugin is not None and plugin.customization.led_spec is not None: + color, _style = render_led_spec(plugin.customization.led_spec, plugin.output_values) + elif fs.toggled: + color = get_category_color(fs.category) if fs.category is not None else (255, 255, 255) + else: + color = None if fs.led is not None: if color is None: fs.led.off() else: fs.led.on() + def _bound_plugin(self, fs): + 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 + def poll_wifi(self): self.wifi_manager.poll() diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index a7a2c056d..ec213fe4f 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -42,7 +42,8 @@ from plugins.customization import lookup as plugin_lookup import modalapi.external_midi as ExternalMidi from modalapi.external_midi import EXTERNAL_INSTANCE_ID -from modalapi.footswitch_behavior import LedDisplayStyle +from modalapi.led_render import LedDisplayStyle, render_led_spec +from pistomp.category import get_category_color from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute from pistomp.lcd320x240 import Lcd @@ -354,33 +355,34 @@ def _taptempo_footswitch(self): 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()) taptempo_fs = self._taptempo_footswitch() for fs in self.hardware.footswitches: - b = fs.behavior - if b is None: - continue - # The taptempo footswitch is driven by the metronome (transport- - # anchored beat grid, or taptempo blink when unanchored), not by - # its default behavior. All other footswitches use their behavior. if fs is taptempo_fs: - self._render_taptempo_led(fs, beat) + color, style = self._render_taptempo(fs, beat) else: - self._render_behavior_led(fs, b, beat) - - def _render_taptempo_led(self, fs: Footswitch, beat: TickState) -> None: - """The taptempo footswitch flashes from whichever beat source is active: - transport-anchored beat grid, or taptempo.anchor + bpm when unanchored.""" + 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: 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 beat.is_anchored: if beat.is_flashing: - rgb = _METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB - self._write_led(fs, rgb) - else: - self._write_led_off(fs) - return + return (_METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB), LedDisplayStyle.SOLID + return None, LedDisplayStyle.SOLID # Unanchored: if taptempo is enabled with a bpm, blink from the taptempo # phase (on for the first ~100ms of each beat period). This replaces the # old gpiozero hardware blink() with a 10ms-driver-computed on/off. @@ -390,62 +392,59 @@ def _render_taptempo_led(self, fs: Footswitch, beat: TickState) -> None: elapsed = now_s - fs.taptempo.anchor phase_in_beat = elapsed % period if phase_in_beat < 0.1: # 100ms on-window - self._write_led(fs, _METRONOME_BEAT_RGB) - else: - self._write_led_off(fs) - return - # Taptempo disabled or no bpm yet: fall through to the default behavior. - b = fs.behavior - if b is not None: - color = b.led_color(beat) - if color is None: - self._write_led_off(fs) - else: - self._write_led(fs, color) + return _METRONOME_BEAT_RGB, LedDisplayStyle.SOLID + return None, LedDisplayStyle.SOLID + # Taptempo disabled or 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 = get_category_color(fs.category) if fs.category is not None else (255, 255, 255) + return color, LedDisplayStyle.SOLID + + 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 _render_behavior_led(fs: Footswitch, b, beat: TickState) -> None: - color = b.led_color(beat) - style = b.led_style(beat) + def _write_led( + fs: Footswitch, + color: tuple[int, int, int] | None, + style: LedDisplayStyle, + beat: TickState, + ) -> None: + """The one writer for fs.pixel/fs.led. Applies the metronome brightness + envelope uniformly for any METRONOME-style frame while transport is + anchored; everything else (including taptempo's already-final on/off + frames) is written as-is.""" + if color is not None and style == LedDisplayStyle.METRONOME and beat.is_anchored: + brightness = 1.0 if beat.is_bar_start else 1.0 - (beat.beat_phase * 0.7) + color = (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) 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 style == LedDisplayStyle.METRONOME and beat.is_anchored: - phase = beat.beat_phase - brightness = 1.0 - (phase * 0.7) - if beat.is_bar_start: - brightness = 1.0 - scaled = tuple(int(c * brightness) for c in color) - if fs.pixel is not None: - fs.pixel.set_color(scaled) - fs.pixel.set_enable(True) - if fs.led is not None: - fs.led.on() - else: - 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() - - @staticmethod - def _write_led(fs: Footswitch, color: tuple[int, int, int]) -> None: 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() - @staticmethod - def _write_led_off(fs: Footswitch) -> None: - if fs.pixel is not None: - fs.pixel.set_enable(False) - if fs.led is not None: - fs.led.off() - def poll_wifi(self): self.wifi_manager.poll() if self._lcd is not None and self.lcd.wifi_menu is not None: @@ -748,9 +747,10 @@ def _handle_ws_message(self, msg: WebSocketMessage): elif isinstance(msg, OutputSetMessage): if self._current is not None: - for fs in self.hardware.footswitches: - if fs.behavior is not None and fs.parameter is not None and fs.parameter.instance_id == msg.instance: - fs.behavior.on_output(msg.symbol, msg.value) + for plugin in self.current.pedalboard.plugins: + if plugin.instance_id == msg.instance: + plugin.set_output_value(msg.symbol, msg.value) + break def _handle_dynamic_plugin_add(self, msg: AddPluginMessage) -> None: """Handle an `add` WS message for a plugin not yet in the pedalboard model.""" @@ -1014,17 +1014,19 @@ def bind_current_pedalboard(self): self._update_interesting_outputs() def _update_interesting_outputs(self) -> None: - """Recompute the WS output_set subscription set from footswitch behaviors.""" - if self.hardware is 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 fs in self.hardware.footswitches: - b = fs.behavior - if b is None or fs.parameter is None: - continue - for sym in b.output_subscriptions(): - keys.add(f"{fs.parameter.instance_id}/{sym}") + 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 _redraw_after_binding(self, controller, is_footswitch): @@ -1034,11 +1036,6 @@ def _redraw_after_binding(self, controller, is_footswitch): else: self.lcd.draw_analog_assignments(self.current.analog_controllers) - def _on_footswitch_binding_changed(self) -> None: - # A live MIDI-learn attached a behavior to a footswitch; recompute the - # WS output_set subscription set so the behavior receives on_output. - self._update_interesting_outputs() - def pedalboard_change(self, pedalboard: Pedalboard.Pedalboard) -> None: logging.info("Pedalboard change") self.lcd.draw_info_message("Loading...") diff --git a/modalapi/plugin.py b/modalapi/plugin.py index bb5b6b93e..8c52e1b63 100755 --- a/modalapi/plugin.py +++ b/modalapi/plugin.py @@ -62,6 +62,10 @@ def __init__( self.category: str | None = category self.uri: str | None = uri self.pedalboard_snapshot: dict[str, 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) @@ -71,6 +75,22 @@ 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) + 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 0d3a0a618..3782682aa 100644 --- a/modalapi/plugin_customization.py +++ b/modalapi/plugin_customization.py @@ -9,7 +9,6 @@ from common.color import RectBorder if TYPE_CHECKING: - from modalapi.footswitch_behavior import FootswitchBehavior from modalapi.plugin import Plugin from plugins.base import PluginPanel @@ -28,6 +27,29 @@ 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. + """ + + state_symbol: str + colors: dict[int, tuple[int, int, int]] + pulse: bool = False + off_states: frozenset[int] = frozenset() + steady_states: frozenset[int] = frozenset() + downbeat_symbol: str | None = None + downbeat_tint: int = 60 + + @dataclass(frozen=True) class PluginCustomization: panel_cls: type[PluginPanel] | None = None @@ -38,9 +60,7 @@ class PluginCustomization: tile_active_color: tuple[int, int, int] | None = None tile_border: RectBorder | None = None extra_data: PluginExtraData | None = None - footswitch_behavior_fn: Callable[[Plugin], FootswitchBehavior | None] | None = field( - default=None, compare=False, hash=False - ) + led_spec: LedSpec | None = None class Customizer(Protocol): diff --git a/modalapi/ws_protocol.py b/modalapi/ws_protocol.py index c886d4e3e..654ebbccb 100644 --- a/modalapi/ws_protocol.py +++ b/modalapi/ws_protocol.py @@ -98,12 +98,19 @@ class TransportMessage: @dataclass class BeatSyncMessage: - """A downbeat occurred. `t_us` is back-dated to the actual downbeat frame (CLOCK_MONOTONIC).""" + """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.""" - bar: int t_us: int bpm: float bpb: float + beat_in_bar: float @dataclass @@ -331,14 +338,14 @@ def parse_message(raw_message: str) -> WebSocketMessage: bpm = float(rest.split()[1]) return TransportMessage(rolling=rolling != "0", bpm=bpm) - # Format: beat_sync {bar} {t_us} {bpm} {bpb} - case ["beat_sync", bar, rest]: - t_us, bpm, bpb = rest.split(" ") + # 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( - bar=int(bar), t_us=int(t_us), bpm=float(bpm), bpb=float(bpb), + beat_in_bar=float(beat_in_bar), ) except (ValueError, IndexError) as e: diff --git a/pistomp/beatsync.py b/pistomp/beatsync.py index 03ebb9538..413b09a32 100644 --- a/pistomp/beatsync.py +++ b/pistomp/beatsync.py @@ -20,6 +20,12 @@ FLASH_US = 80_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) @@ -33,9 +39,18 @@ class TickState: 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_beat_idx: int = 0 + self._anchor_pos: float = 0.0 self._bpm: float = 120.0 self._bpb: float = 4.0 self._last_beat_idx: int = 0 @@ -47,18 +62,31 @@ def is_anchored(self) -> bool: return self._anchor_t_us is not None def on_anchor(self, msg: BeatSyncMessage) -> None: - bpb = msg.bpb if msg.bpb > 0 else 0 + if msg.bpm <= 0 or msg.bpb <= 0: + self.clear() + return self._anchor_t_us = msg.t_us - self._anchor_beat_idx = msg.bar * int(bpb) if bpb else 0 + self._anchor_pos = msg.beat_in_bar self._bpm = msg.bpm self._bpb = msg.bpb - self._last_beat_idx = self._anchor_beat_idx 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_beat_idx = 0 + self._anchor_pos = 0.0 self._last_beat_idx = 0 self._flash_end_us = None self._last_crossing_was_bar_start = False @@ -77,16 +105,14 @@ def tick(self, now_us: int) -> TickState: bpb_int = int(self._bpb) delta_us = now_us - self._anchor_t_us - delta_beats = delta_us * self._bpm / 60_000_000.0 - current_beat_idx = self._anchor_beat_idx + int(delta_beats) - beat_phase = delta_beats - int(delta_beats) # fractional part [0, 1) + 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) if current_beat_idx > self._last_beat_idx: self._last_beat_idx = current_beat_idx self._flash_end_us = now_us + FLASH_US - self._last_crossing_was_bar_start = ( - bpb_int > 0 and (current_beat_idx % bpb_int) == 0 - ) + 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 diff --git a/pistomp/controller_manager.py b/pistomp/controller_manager.py index b65b42ea5..ceb156989 100644 --- a/pistomp/controller_manager.py +++ b/pistomp/controller_manager.py @@ -21,7 +21,6 @@ import common.token as Token from common.parameter import Parameter, TTL_PROPERTIES, TTL_INTEGER from modalapi.external_midi import EXTERNAL_INSTANCE_ID -from modalapi.footswitch_behavior import attach_footswitch_behavior from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.controller import AnalogDisplayInfo from pistomp.current import Current @@ -94,7 +93,6 @@ def _bind_plugin_parameters(self, current) -> list: plugin.has_footswitch = True footswitch_plugins.append(plugin) controller.set_category(plugin.category) - attach_footswitch_behavior(controller, plugin) else: key = "%s:%s" % (plugin.instance_id, param.name) display_info = controller.get_display_info() diff --git a/pistomp/footswitch.py b/pistomp/footswitch.py index a83defeaf..d922f7faf 100755 --- a/pistomp/footswitch.py +++ b/pistomp/footswitch.py @@ -15,7 +15,6 @@ import logging import sys -from typing import TYPE_CHECKING from typing_extensions import override import common.token as Token @@ -25,9 +24,6 @@ import pistomp.switchstate as switchstate from pistomp.input.event import SwitchEvent, SwitchEventKind -if TYPE_CHECKING: - from modalapi.footswitch_behavior import FootswitchBehavior - class Footswitch(controller.Controller): @@ -49,7 +45,6 @@ def __init__(self, id: int | None, led_pin, pixel, midi_CC, midi_channel, refres self.longpress_midi_CC = None self.disabled = False self.taptempo = taptempo - self.behavior: FootswitchBehavior | None = None if adc_input and gpio_input: logging.error("Switch cannot be specified with both %s and %s", (Token.ADC_INPUT, Token.GPIO_INPUT)) @@ -186,10 +181,4 @@ def clear_pedalboard_info(self): self.preset_callback = None self.preset_callback_arg = None self.parameter = None - # Reset to the default behavior; ControllerManager.bind overlays a - # plugin-specific one if this switch binds to a plugin parameter. - # Preset/relay/unbound switches keep the default so the per-tick LED - # driver still lights their pixel. - from modalapi.footswitch_behavior import DefaultFootswitchBehavior - self.behavior = DefaultFootswitchBehavior(self) self.clear_relays() diff --git a/pistomp/handler.py b/pistomp/handler.py index b30cda43e..a5600dab0 100755 --- a/pistomp/handler.py +++ b/pistomp/handler.py @@ -21,7 +21,6 @@ from rtmidi.midiconstants import CONTROL_CHANGE -from modalapi.footswitch_behavior import attach_footswitch_behavior from pistomp.analogmidicontrol import AnalogMidiControl from pistomp.current import Current from pistomp.encoder_controller import EncoderController @@ -173,7 +172,7 @@ def _handle_footswitch(self, fs: "Footswitch", kind: SwitchEventKind, timestamp: fs.preset_callback() return True if fs.midi_CC is not None: - if fs.behavior is not None and fs.behavior.momentary: + if fs.parameter is not None and fs.parameter.is_momentary: self._emit_midi(fs, 127) else: fs.toggled = not fs.toggled @@ -293,7 +292,6 @@ def _bind_controller_to_param(self, plugin, param, controller) -> bool: # TODO sort this list so selection orders correctly (sort on midi_CC?) plugin.has_footswitch = True controller.set_category(plugin.category) - attach_footswitch_behavior(controller, plugin) return True elif isinstance(controller, (AnalogMidiControl, EncoderController)): key = "%s:%s" % (plugin.instance_id, param.name) diff --git a/pistomp/hardware.py b/pistomp/hardware.py index 72e11c0fc..4db79a9ff 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -28,7 +28,6 @@ from abc import ABC, abstractmethod from rtmidi import MidiOut from modalapi.external_midi import ExternalMidiManager, EXTERNAL_INSTANCE_ID -from modalapi.footswitch_behavior import DefaultFootswitchBehavior from pistomp.input.sink import InputSink from pistomp.controller import Controller, RoutingInfo, RoutingDestination import pistomp.relay as Relay @@ -265,12 +264,6 @@ def create_footswitches(self, cfg): (gpio_input, midi_channel, midi_cc)) assert fs is not None, "No footswitch created for config: %s" % f - # Every footswitch always has a behavior. Plugin-bound switches get - # a plugin-specific one via ControllerManager.bind; preset/relay/ - # unbound switches keep this default (toggle + category color) so - # the per-tick LED driver lights their pixel too. - if fs.behavior is None: - fs.behavior = DefaultFootswitchBehavior(fs) self.footswitches.append(fs) idx += 1 diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py index 7922d6711..bda9e7809 100644 --- a/plugins/loopjefe/__init__.py +++ b/plugins/loopjefe/__init__.py @@ -1,88 +1,50 @@ """LoopJefe multitrack looper plugin customization. -Registers footswitch behavior for loopjefe-lv2: momentary short-press -semantics, output_set subscriptions for state and measure_number, and -per-tick LED rendering (state color + beat pulse). +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 -from collections.abc import Iterable -from typing import TYPE_CHECKING - -from modalapi.footswitch_behavior import LedDisplayStyle -from modalapi.plugin import Plugin -from plugins.customization import PluginCustomization, register - -if TYPE_CHECKING: - from pistomp.beatsync import TickState - +from modalapi.plugin_customization import LedSpec, PluginCustomization +from plugins.customization import register 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_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 } -_DOWNBEAT_TINT = 60 # added to each channel for the loop-downbeat variant - - -def _brighten(c: tuple[int, int, int]) -> tuple[int, int, int]: - return (min(255, c[0] + _DOWNBEAT_TINT), min(255, c[1] + _DOWNBEAT_TINT), min(255, c[2] + _DOWNBEAT_TINT)) - - -class LoopjefeBehavior: - def __init__(self, plugin: Plugin) -> None: - self._instance_id = plugin.instance_id - self._state: int = 0 - self._measure_number: int = 0 - - @property - def momentary(self) -> bool: - return True - - def output_subscriptions(self) -> Iterable[str]: - return ("state", "measure_number") - - def on_output(self, symbol: str, value: float) -> None: - if symbol == "state": - self._state = int(value) - elif symbol == "measure_number": - self._measure_number = int(value) - - def led_color(self, beat: TickState) -> tuple[int, int, int] | None: - if self._state == 0: - return None - if self._state == 5: - return (80, 80, 80) - base = _STATE_COLORS.get(self._state, (80, 80, 80)) - if self._measure_number == 0: - return _brighten(base) - return base - - def led_style(self, beat: TickState) -> LedDisplayStyle: - if self._state == 0 or self._state == 5: - return LedDisplayStyle.SOLID - return LedDisplayStyle.METRONOME - - -def make_loopjefe_behavior(plugin: Plugin) -> LoopjefeBehavior: - return LoopjefeBehavior(plugin) - +_LOOPJEFE_LED_SPEC = LedSpec( + state_symbol="state", + colors=_STATE_COLORS, + pulse=True, + off_states=frozenset({_STATE_EMPTY}), + steady_states=frozenset({_STATE_STOPPED}), + downbeat_symbol="measure_number", + downbeat_tint=60, +) register( *LOOPJEFE_URIS, customization=PluginCustomization( display_name="LoopJefe", - footswitch_behavior_fn=make_loopjefe_behavior, + led_spec=_LOOPJEFE_LED_SPEC, ), ) diff --git a/tests/input_router/test_handle_footswitch_longpress.py b/tests/input_router/test_handle_footswitch_longpress.py index 81cae1346..ae96d5d7c 100644 --- a/tests/input_router/test_handle_footswitch_longpress.py +++ b/tests/input_router/test_handle_footswitch_longpress.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock +from common.parameter import Parameter, Type from pistomp.footswitch import Footswitch from pistomp.handler import Handler from pistomp.input.event import SwitchEventKind @@ -81,35 +82,22 @@ def test_no_longpress_midi_cc_falls_through_to_chord(self): hw.midiout.send_message.assert_not_called() -class _StubBehavior: - """Minimal behavior stub for momentary-press tests.""" - - def __init__(self, momentary: bool) -> None: - self.momentary = momentary - - def output_subscriptions(self): - return () - - def on_output(self, symbol: str, value: float) -> None: - pass - - def led_color(self, beat): - return None - - def led_style(self, beat): - from modalapi.footswitch_behavior import LedDisplayStyle - return LedDisplayStyle.SOLID +def _make_parameter(port_type: Type) -> Parameter: + info = {"symbol": "advance", "ranges": {"minimum": 0, "maximum": 1}} + p = Parameter(info, value=0.0, binding=None, instance_id="loopjefe") + p.type = port_type + return p class TestMomentaryShortPress: - """A footswitch whose behavior.momentary is True emits 127 every press - (rising-edge trigger semantics) — no toggled flip. Otherwise the existing - toggle behavior (127/0 alternating) is preserved.""" + """A footswitch bound to a pprops:trigger port (Parameter.is_momentary) + emits 127 every press (rising-edge trigger semantics) — no toggled flip. + Otherwise the existing toggle behavior (127/0 alternating) is preserved.""" def test_momentary_emits_127_every_press(self): handler, hw = _make_handler() fs = _make_footswitch(midi_CC=10) - fs.behavior = _StubBehavior(momentary=True) + fs.parameter = _make_parameter(Type.TRIGGER) for _ in range(3): handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) @@ -122,7 +110,7 @@ def test_momentary_emits_127_every_press(self): def test_non_momentary_toggles_as_before(self): handler, hw = _make_handler() fs = _make_footswitch(midi_CC=10) - fs.behavior = _StubBehavior(momentary=False) + fs.parameter = _make_parameter(Type.DEFAULT) handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=2.0) @@ -133,12 +121,12 @@ def test_non_momentary_toggles_as_before(self): assert messages[1][2] == 0 # second press → toggled False → 0 assert fs.toggled is False - def test_no_behavior_toggles_as_before(self): - """Regression guard: a footswitch with behavior=None (e.g. a preset + def test_no_parameter_toggles_as_before(self): + """Regression guard: a footswitch with parameter=None (e.g. a preset switch that never went through ControllerManager.bind) still toggles.""" handler, hw = _make_handler() fs = _make_footswitch(midi_CC=10) - fs.behavior = None + fs.parameter = None handler._handle_footswitch(fs, SwitchEventKind.PRESS, timestamp=1.0) diff --git a/tests/test_beatsync.py b/tests/test_beatsync.py index 0210fced9..49a539aa9 100644 --- a/tests/test_beatsync.py +++ b/tests/test_beatsync.py @@ -4,8 +4,8 @@ from pistomp.beatsync import FLASH_US, STALE_AFTER_US, BeatGrid, TickState -def _anchor(bar=0, t_us=0, bpm=120.0, bpb=4.0) -> BeatSyncMessage: - return BeatSyncMessage(bar=bar, t_us=t_us, bpm=bpm, bpb=bpb) +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: @@ -28,19 +28,33 @@ def test_clear_is_idempotent(self): class TestAnchor: def test_anchor_marks_anchored(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + g.on_anchor(_anchor(t_us=1_000_000, bpm=120.0, bpb=4.0)) assert g.is_anchored is True - def test_anchor_does_not_flash_immediately(self): + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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 @@ -53,21 +67,21 @@ def test_anchor_at_late_time_does_not_catch_up(self): class TestFlash: def test_first_beat_after_anchor_flashes(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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_bar_start_marked_on_downbeat(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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) @@ -77,7 +91,7 @@ def test_bar_start_marked_on_downbeat(self): def test_subsequent_beats_flash_in_sequence(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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) @@ -87,7 +101,7 @@ def test_subsequent_beats_flash_in_sequence(self): def test_subsequent_bar_starts_every_bpb_beats(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=0, bpm=120.0, bpb=4.0)) + 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) @@ -99,7 +113,7 @@ def test_subsequent_bar_starts_every_bpb_beats(self): class TestClear: def test_clear_after_anchor(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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) @@ -107,7 +121,7 @@ def test_clear_after_anchor(self): def test_clear_mid_flash(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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) @@ -117,13 +131,13 @@ def test_clear_mid_flash(self): class TestStaleTimeout: def test_stale_anchor_clears_on_tick(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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 @@ -131,13 +145,13 @@ def test_freshly_anchored_is_not_stale(self): class TestInvalidAnchor: def test_zero_bpm_clears_grid(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=0.0, bpb=4.0)) + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=0.0)) + 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 @@ -145,17 +159,17 @@ def test_zero_bpb_clears_grid(self): class TestReAnchor: def test_re_anchor_resets_beat_counter(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) + 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(bar=5, t_us=10_000_000, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=1_000_000, bpm=120.0, bpb=4.0)) - g.on_anchor(_anchor(bar=0, t_us=1_000_000 + 4_000_000, bpm=120.0, bpb=4.0)) + 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 @@ -180,27 +194,27 @@ class TestBeatPhase: def test_phase_zero_at_beat_boundary(self): g = BeatGrid() - g.on_anchor(_anchor(bar=0, t_us=0, bpm=120.0, bpb=4.0)) # 120bpm → 500ms/beat + 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(bar=0, t_us=0, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=0, bpm=120.0, bpb=4.0)) + 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(bar=0, t_us=0, bpm=120.0, bpb=4.0)) + 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 diff --git a/tests/test_loopjefe_behavior.py b/tests/test_loopjefe_behavior.py index 3991ac240..2d81b0530 100644 --- a/tests/test_loopjefe_behavior.py +++ b/tests/test_loopjefe_behavior.py @@ -1,40 +1,28 @@ -"""LoopJefe footswitch behavior — state→color/style contract + registration. +"""LoopJefe footswitch LED spec — state->color/style contract + registration. Pins: - The loopjefe URIs are registered (plugins/__init__.py imports plugins.loopjefe). - - make_loopjefe_behavior produces a behavior with momentary=True and the - expected output_subscriptions. - - led_color / led_style for all 9 states, including the measure_number==0 - loop-downbeat tint. - - Brightness/pulse is the driver's job — not tested here. + - 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.footswitch_behavior import LedDisplayStyle -from modalapi.plugin import Plugin -from pistomp.beatsync import TickState +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, make_loopjefe_behavior +from plugins.loopjefe import LOOPJEFE_URIS -def _tick(beat_phase: float = 0.0, is_bar_start: bool = False) -> TickState: - return TickState( - is_anchored=True, - is_flashing=is_bar_start, - is_bar_start=is_bar_start, - bpm=120.0, - bpb=4.0, - beat_phase=beat_phase, - ) - - -def _make_plugin(uri: str = LOOPJEFE_URIS[0]) -> Plugin: - customization = lookup(uri) - # Plugin() requires a parameters dict; loopjefe behavior only reads instance_id. - return Plugin("loopjefe", {}, {}, "Looper", uri=uri, customization=customization) +def _spec() -> LedSpec: + spec = lookup(LOOPJEFE_URIS[0]).led_spec + assert spec is not None + return spec class TestRegistration: @@ -43,61 +31,34 @@ def test_loopjefe_uris_are_registered(self): 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_customization_with_behavior_fn(self): + def test_lookup_returns_loopjefe_led_spec(self): for uri in LOOPJEFE_URIS: cust = lookup(uri) - assert cust.footswitch_behavior_fn is make_loopjefe_behavior, ( - f"lookup({uri!r}) did not return the loopjefe customization" - ) - - -class TestBehaviorContract: - def test_momentary_short_press(self): - b = make_loopjefe_behavior(_make_plugin()) - assert b.momentary is True - - def test_output_subscriptions_state_and_measure_number(self): - b = make_loopjefe_behavior(_make_plugin()) - assert set(b.output_subscriptions()) == {"state", "measure_number"} - - def test_on_output_caches_state(self): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", 2.0) - b.on_output("measure_number", 1.0) # non-downbeat so base color is returned - assert b.led_color(_tick()) == (255, 0, 0) # Recording - - def test_on_output_caches_measure_number(self): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", 1.0) # Record Arm → blue - b.on_output("measure_number", 0.0) - # measure_number == 0 → downbeat tint (brighter) - assert b.led_color(_tick()) != (0, 80, 255) - b.on_output("measure_number", 3.0) - assert b.led_color(_tick()) == (0, 80, 255) + 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 + (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): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", float(state)) - b.on_output("measure_number", 1.0) # not the loop downbeat - assert b.led_color(_tick()) == 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 + (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), @@ -106,19 +67,14 @@ def test_state_color_with_nonzero_measure(self, state, expected_color): (8, LedDisplayStyle.METRONOME), ]) def test_state_style(self, state, expected_style): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", float(state)) - assert b.led_style(_tick()) == 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): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", 2.0) # Recording → red - b.on_output("measure_number", 0.0) - downbeat = b.led_color(_tick()) - b.on_output("measure_number", 2.0) - normal = b.led_color(_tick()) + 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 @@ -126,7 +82,5 @@ def test_measure_zero_returns_distinct_color(self): assert any(d > n for d, n in zip(downbeat, normal)) def test_measure_zero_empty_state_still_off(self): - b = make_loopjefe_behavior(_make_plugin()) - b.on_output("state", 0.0) - b.on_output("measure_number", 0.0) - assert b.led_color(_tick()) is None \ No newline at end of file + color, _style = render_led_spec(_spec(), {"state": 0.0, "measure_number": 0.0}) + assert color is None diff --git a/tests/test_ws_protocol.py b/tests/test_ws_protocol.py index ff60d15e7..7a7404afa 100644 --- a/tests/test_ws_protocol.py +++ b/tests/test_ws_protocol.py @@ -238,25 +238,27 @@ def test_transport_malformed_bpm_is_unknown(): # --------------------------------------------------------------------------- -# beat_sync (beat_sync {bar} {t_us} {bpm} {bpb}) +# 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 5 1234567890 120.0 4") == BeatSyncMessage( - bar=5, t_us=1234567890, bpm=120.0, bpb=4.0 + 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_bar_zero(): - assert parse_message("beat_sync 0 0 60.0 3") == BeatSyncMessage( - bar=0, t_us=0, bpm=60.0, bpb=3.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 1 1000000 90.5 7") == BeatSyncMessage( - bar=1, t_us=1000000, bpm=90.5, bpb=7.0 + 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 ) diff --git a/tests/v3/test_footswitch_led_driver.py b/tests/v3/test_footswitch_led_driver.py index 0183bb642..2d43cf59f 100644 --- a/tests/v3/test_footswitch_led_driver.py +++ b/tests/v3/test_footswitch_led_driver.py @@ -2,24 +2,25 @@ 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 behavior query — no separate set_led path. +fs.led are written from the same (color, style) frame in the same driver call +— no separate set_led path. Covers: - - SOLID: shows the behavior's color steadily (or off when color is None). + - SOLID: shows the frame's color steadily (or off when color is None). - METRONOME: scales brightness by beat_phase (bright at 0, dim toward 1). - Unanchored METRONOME: steady color (no pulse). - - Off: color is None → pixel disabled, GPIO LED off. + - 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. - - Taptempo footswitch: transport-anchored → beat-synced flash; taptempo-only - → blink from taptempo.anchor + bpm; taptempo disabled → default behavior. + - 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.footswitch_behavior import LedDisplayStyle +from modalapi.led_render import LedDisplayStyle from modalapi.modhandler import Modhandler from pistomp.beatsync import TickState from pistomp.footswitch import Footswitch @@ -40,36 +41,18 @@ def _beat(beat_phase: float = 0.0, *, is_anchored: bool = True, ) -class _StubBehavior: - """Minimal behavior stub for driver tests — returns a fixed color/style.""" - - def __init__(self, color, style: LedDisplayStyle = LedDisplayStyle.SOLID, - momentary: bool = False) -> None: - self._color = color - self._style = style - self.momentary = momentary - - def output_subscriptions(self): - return () - - def on_output(self, symbol: str, value: float) -> None: - pass - - def led_color(self, beat: TickState): - return self._color - - def led_style(self, beat: TickState) -> LedDisplayStyle: - return self._style - - def _drive(handler: Modhandler, beat: TickState) -> None: """Invoke the driver directly with a fabricated beat state.""" handler._drive_footswitch_leds(beat) -def _fs_with_behavior(v3_system: SystemFixture, behavior) -> Footswitch: +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] - fs.behavior = behavior + v3_system.handler._render_footswitch = MagicMock(return_value=(color, style)) # type: ignore[method-assign] fs.pixel = MagicMock() fs.led = MagicMock() return fs @@ -77,7 +60,7 @@ def _fs_with_behavior(v3_system: SystemFixture, behavior) -> Footswitch: class TestSolidStyle: def test_solid_shows_color_steadily(self, v3_system: SystemFixture): - fs = _fs_with_behavior(v3_system, _StubBehavior((0, 255, 0), LedDisplayStyle.SOLID)) + 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) @@ -85,13 +68,13 @@ def test_solid_shows_color_steadily(self, v3_system: SystemFixture): 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_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.SOLID)) + 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_behavior(v3_system, _StubBehavior(None, LedDisplayStyle.SOLID)) + 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 @@ -100,13 +83,13 @@ def test_solid_none_color_disables_pixel_and_led(self, v3_system: SystemFixture) class TestMetronomeStyle: def test_metronome_bright_at_phase_zero(self, v3_system: SystemFixture): - fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) _drive(v3_system.handler, _beat(beat_phase=0.0)) # phase 0 → brightness 1.0 → unscaled color fs.pixel.set_color.assert_called_once_with((100, 100, 100)) def test_metronome_dim_near_phase_one(self, v3_system: SystemFixture): - fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) _drive(v3_system.handler, _beat(beat_phase=0.9)) scaled = fs.pixel.set_color.call_args.args[0] # phase 0.9 → brightness 1.0 - 0.9*0.7 = 0.37 → 100*0.37 = 37 @@ -114,41 +97,58 @@ def test_metronome_dim_near_phase_one(self, v3_system: SystemFixture): assert all(c < 100 for c in scaled) def test_metronome_brightest_on_bar_start(self, v3_system: SystemFixture): - fs = _fs_with_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) _drive(v3_system.handler, _beat(beat_phase=0.5, is_bar_start=True)) # bar start forces brightness 1.0 even though phase is mid-beat 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_behavior(v3_system, _StubBehavior((100, 100, 100), LedDisplayStyle.METRONOME)) + fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) _drive(v3_system.handler, _beat(beat_phase=0.9, is_anchored=False)) # No pulse when unanchored — full color fs.pixel.set_color.assert_called_once_with((100, 100, 100)) -class TestNoBehavior: - def test_footswitch_without_behavior_is_skipped(self, v3_system: SystemFixture): +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.behavior = None + fs.parameter = None + fs.toggled = False fs.pixel = MagicMock() + fs.led = MagicMock() _drive(v3_system.handler, _beat()) - fs.pixel.set_enable.assert_not_called() - fs.pixel.set_color.assert_not_called() + 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 behavior query in the + """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_behavior(v3_system, _StubBehavior((0, 255, 0), LedDisplayStyle.SOLID)) + 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_behavior(v3_system, _StubBehavior(None, LedDisplayStyle.SOLID)) + 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 @@ -175,7 +175,7 @@ class TestDriverRunsInPollControls: 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_behavior(v3_system, _StubBehavior((0, 255, 0), LedDisplayStyle.SOLID)) + 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)) @@ -184,8 +184,8 @@ def test_poll_controls_drives_leds(self, v3_system: SystemFixture): 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_behavior(v3_system, _StubBehavior((0, 255, 0), LedDisplayStyle.SOLID)) + 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() \ No newline at end of file + fs.pixel.set_enable.assert_not_called() diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 8a093de6c..5111df68f 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -112,35 +112,30 @@ def test_v3_midi_learn_unknown_instance_is_ignored(v3_system: SystemFixture, mak def _make_loopjefe_plugin_with_advance(make_parameter, instance_id="loopjefe"): - """Build a loopjefe plugin with an `advance` parameter, using the loopjefe - customization directly (so the test doesn't depend on plugins/__init__.py - importing plugins.loopjefe).""" + """Build a loopjefe plugin with an `advance` trigger parameter, using the + real registered loopjefe customization (plugins/__init__.py imports + plugins.loopjefe, which registers its LedSpec).""" + from common.parameter import Type from modalapi.plugin import Plugin - from modalapi.plugin_customization import PluginCustomization - from plugins.loopjefe import make_loopjefe_behavior + from plugins import lookup + from plugins.loopjefe import LOOPJEFE_URIS advance = make_parameter("advance", instance_id, value=0.0) - customization = PluginCustomization( - display_name="LoopJefe", - footswitch_behavior_fn=make_loopjefe_behavior, - ) + advance.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl + uri = LOOPJEFE_URIS[0] return Plugin(instance_id, {"advance": advance}, {}, "Looper", - uri="http://treefallsound.com/plugins/loopjefe", - customization=customization) + uri=uri, customization=lookup(uri)) -class TestMidiLearnAttachesBehavior: +class TestMidiLearnBindsMomentaryAndOutputs: """Regression: the live MIDI-learn path (Handler._apply_midi_binding → - _bind_controller_to_param) must attach a FootswitchBehavior when the plugin - has a footswitch_behavior_fn, and must refresh the WS output_set - subscription set. Without this, a footswitch MIDI-learned to loopjefe's - `advance` after pedalboard load keeps DefaultFootswitchBehavior (toggle, - no output_set subscription) — the LED never lights and short-press toggles - 127/0 instead of sending a momentary 127.""" - - def test_midi_learn_attaches_loopjefe_behavior(self, v3_system: SystemFixture, make_parameter): - from plugins.loopjefe import LoopjefeBehavior + _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 @@ -156,65 +151,52 @@ def test_midi_learn_attaches_loopjefe_behavior(self, v3_system: SystemFixture, m handler.poll_ws_messages() assert fs0.parameter is plugin.parameters["advance"] - assert isinstance(fs0.behavior, LoopjefeBehavior), ( - "midi_map must attach the plugin's footswitch_behavior_fn — " - "without it the LED never lights and short-press toggles instead of momentary" + 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_midi_learn_refreshes_interesting_outputs(self, v3_system: SystemFixture, make_parameter): - """Regression: after midi_map binds a footswitch to a plugin with - output_subscriptions, the WS bridge's interesting-set must include - those subscriptions so the behavior actually receives on_output calls.""" + 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 - 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.interesting_calls.clear() - ws_bridge.inject(f"midi_map /graph/loopjefe advance {channel} {cc} 0.0 1.0") - handler.poll_ws_messages() + handler._update_interesting_outputs() - # The last set_interesting_outputs call must include the loopjefe - # output_subscriptions for the bound instance. - assert ws_bridge.interesting_calls, "midi_map must trigger _update_interesting_outputs" - last = ws_bridge.interesting_calls[-1] + last = v3_system.ws_bridge.interesting_calls[-1] assert "loopjefe/state" in last assert "loopjefe/measure_number" in last - def test_output_set_reaches_bound_behavior(self, v3_system: SystemFixture, make_parameter): - """End-to-end: after midi_map binds fs0 to loopjefe/advance, an - output_set for loopjefe/state must update the behavior's cached state - so the LED driver renders the right color.""" - from modalapi.footswitch_behavior import LedDisplayStyle - from pistomp.beatsync import TickState + 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 - 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() - - # Drive a state change from mod-ui 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() - beat = TickState(True, False, False, 120.0, 4.0, 0.0) - assert fs0.behavior is not None - color = fs0.behavior.led_color(beat) + 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 fs0.behavior.led_style(beat) == LedDisplayStyle.METRONOME + assert style == LedDisplayStyle.METRONOME From 6f04fe5c5ff3439c6ba5700777d8f1e592e96e20 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Mon, 6 Jul 2026 21:01:44 -0400 Subject: [PATCH 8/8] Remove docs --- docs/multitrack-looper-plan.md | 838 ------------------------------ docs/plan-footswitch-behaviour.md | 303 ----------- 2 files changed, 1141 deletions(-) delete mode 100644 docs/multitrack-looper-plan.md delete mode 100644 docs/plan-footswitch-behaviour.md diff --git a/docs/multitrack-looper-plan.md b/docs/multitrack-looper-plan.md deleted file mode 100644 index 73613d9cc..000000000 --- a/docs/multitrack-looper-plan.md +++ /dev/null @@ -1,838 +0,0 @@ -# Multitrack Looper Plan (LoopJefe LV2 + pi-Stomp) - -Implementation fork lives at `~/dev/loopjefe-lv2` (renamed from a clone -of `mod-audio/sooperlooper-lv2-plugin`). The original working title -"multitrack looper" survives in the doc name; the canonical scope -is **three freely-recorded loop tracks of different lengths, locked -to a shared beat grid, controlled from footswitches alone**. - -## Goal - -Give pi-Stomp genuine RC-505-style live looping: several independently -recorded loop tracks of *different*, freely-played lengths (a 4-bar -chord loop, a 16-bar bassline recorded right after it, in either order) -that always land on a shared beat grid — using footswitches alone, and -optionally a richer external MIDI controller — without pi-Stomp becoming -an audio router or instrument coordinator. The reference point -throughout is Marc Rebillet's live workflow (Boss RC-505 + keyboard): -build a song from nothing, live, by layering loops of different lengths -that never drift apart, with nothing pre-declared about how long any -given loop "should" be. - -## Why LV2/mod-host, not the standalone SooperLooper engine - -We evaluated and rejected running the full `essej/sooperlooper` JACK -app (OSC-controlled, natively multitrack within one process), because: - -- It sits **outside** mod-host's pedalboard, so it can't be freely - positioned in the signal chain — no per-track pre/post-loop effects, - which is a hard requirement (users need to choose what processes a - track's input before it's recorded, and what processes its output - after). -- It would require pi-Stomp to own audio routing (JACK port patching) - and, if we want a MIDI-bindable control surface for digital - instruments, instrument/MIDI coordination too — both explicitly out - of scope. pi-Stomp is a control/monitoring layer over mod-host, not - the thing coordinating real audio (the one deliberate exception is - **blend mode**). - -Instead: **three separate instances of the SooperLooper LV2 plugin**, -one per track, placed anywhere in a mod-host pedalboard like any other -plugin, each independently addressable via mod-ui's existing MIDI-learn -system. - -## Researching the reference device: RC-505 (mkI) mode matrix - -Before designing our own behavior we read the actual RC-505 mkI -owner's manual end to end. Four largely independent per-track settings -govern length/sync behavior: - -| Axis | Options | What it controls | -|---|---|---| -| `Loop Sync` | ON / OFF | ON: playback start phase-aligns to the group's shared downbeat. OFF: loops independently, retriggers from its own bar 1. | -| `Measure` (only meaningful if `Loop Sync=ON`) | fixed number (1, 2, 4, 8...) / `AUTO` / `FREE` | Fixed: you type in an exact bar count ahead of time, recording is forced to fit it. `AUTO`: copies whatever the *first-recorded* AUTO track ended up being. `FREE`: *"set automatically, corresponding to the length of the recording"* — whatever you actually played, measured after the fact, nothing pre-declared. | -| `Quantize` (only active if something's already playing/synced to quantize against — rhythm on, an existing synced track, or MIDI sync) | `REC END` / `MEASURE` / `BEAT` | Governs *timing precision at the edges* — snaps record-start (always) and, depending on mode, overdub/playback boundaries to either a measure or a beat. | -| `Tempo Sync` | ON / OFF | ON: track plays at the shared phrase-memory tempo (may stretch). OFF: always plays at its own "original tempo," never stretched. | - -Watching Marc's livestreams (a 4-bar loop, then a 16-bar loop recorded -immediately after, in that order, nothing hesitant or dialed-in -beforehand) is consistent with `Loop Sync=ON` + `Measure=FREE` + -`Quantize=BEAT` + `Tempo Sync=ON` — **not** a fixed/typed-in bar count -on either track. Nothing was pre-declared; both lengths were simply -measured from what he played, snapped to the beat grid, and it worked -because a shared tempo already existed underneath both recordings. - -**Decision: hardcode exactly this combination.** Every track -permanently behaves as `Loop Sync=ON, Measure=FREE, Quantize=BEAT, -Tempo Sync=ON`. This is a deliberately narrow, opinionated first version -matching the one workflow we're targeting, not a general-purpose -emulator. - -## Architecture: how freely-recorded loops of different lengths stay locked (Design A) - -mod-host already implements the LV2 `time:` extension in full -(`mod-host/src/effects.c`). Two facts, confirmed by reading the source, -make this simpler than first assumed: - -- **mod-host is the JACK timebase master** (`jack_set_timebase_callback`, - `JackTimebase` — search the file). It always emits valid BBT - (`pos->valid = JackPositionBBT`), so `bar`, `barBeat` (fractional - 0-indexed beat-within-bar), `beatsPerBar`, `beatUnit`, and - `ticksPerBeat` are authoritative — not just `beatsPerMinute`. -- **While the transport is *rolling*, `pos.frame` advances every audio - cycle, so the change-guard at `effects.c` (the forge site) fires - every `run()`** — mod-host re-forges and pushes a fresh - `time:Position` (`speed`, `frame`, `bar`, `barBeat`, `beatsPerBar`, - `beatsPerMinute`, `ticksPerBeat`) into every subscribed instance - **every block**. It only falls silent when the transport is *stopped* - (frame frozen). - -It reaches any plugin that declares an atom input port supporting it -(`lilv_port_supports_event(..., timePosition)` sets `HINT_TRANSPORT` on -that port). Critically, **this is a broadcast, not a wire**: pushed -automatically into every subscribed instance with zero manual patching -in mod-ui. Real and working today; nothing about it needs to change. - -Given that shared clock (tempo + absolute phase) is established once, -*before* anyone hits record, each of the three LoopJefe instances -implements `FREE`-mode behavior **entirely locally**, with no -instance ever needing to know what another instance did. - -**Quantization is to the measure (bar), not the beat.** This is the -load-bearing behavioral decision. For loops of *different* lengths to -stay musically locked, each loop's length must be an integer number of -*bars* and each must start on a downbeat — then a 16-bar loop is exactly -4× a 4-bar loop's cycle and their downbeats always coincide. Beat-only -quantize would permit a "4 bars + 1 beat" (17-beat) loop that never -lines up with a 16-beat loop's bars, defeating the whole multitrack-lock -goal. Measure alignment also matches the RC-505 faithfully: its record -quantize is `REC END` = "quantize to the measure start location" (mkI -manual p.15) and its count-in is a full **1-measure** count (p.19). -`beatsPerBar` is in the atom and persisted per-pedalboard, so this costs -nothing to read. - -1. Each `run()` while rolling, read the latest `time:Position` from the - `time_info` atom port: cache `frame`, `bar`, `barBeat`, `beatsPerBar`, - `beatsPerMinute`, `speed`. Because mod-host hands us absolute phase - every block, **we never integrate our own frame counter** — no drift. - Derive `beat_length_samples = sample_rate * 60 / bpm` and - `bar_length_samples = beat_length_samples * beatsPerBar` for boundary - math. -2. On `record` press, snap the actual start to the **next bar boundary**, - sample-accurate *within the block* by computing the sample offset to - the next downbeat from the atom's `frame`/`bar`/`barBeat`. The window - between press and that boundary is the count-in (up to a full bar). -3. Record for however long the footswitch is held — genuinely free, - nothing pre-declared, nothing capped. -4. On `record` release, snap the stop so the total recorded length is the - **nearest whole number of bars** (round to closest, not always up or - down), computed from `bar_length_samples`. The resulting loop length - is therefore always an exact whole number of bars, without ever having - been told in advance how many. - -Edge cases the implementer must handle: a release *before* the first -bar boundary has even been reached (recording never really started — -abort cleanly, leave the track empty); and rounding a sub-one-bar take -to a minimum of one bar rather than zero. - -**Precondition — the transport must be rolling.** Beat-sync only works -while mod-host's transport is rolling (frame advancing); when stopped, -`frame`/`barBeat` are frozen and no boundaries can be found. Transport -rolling *and* BPM are **persisted per-pedalboard** (`timeInfo.rolling`, -`timeInfo.bpm`, applied at load), so the "shared clock exists before -anyone records" premise is satisfied by shipping the looper pedalboard -with `rolling: true` and a sensible default BPM. **The plugin must -fall back gracefully** when the transport is stopped or `speed == 0`: -behave as a free-running (unquantized) record rather than never -triggering. - -**pi-Stomp does NOT own the transport.** Deliberate design choice: an -external MIDI controller (or a keyboardist's device) typically owns -play/stop, and we don't want to fight it. pi-Stomp is a pure transport -*slave* — it reads the beat grid (see "Beat-grid sync" below) and never -calls start/stop. Starting the transport is left to: the pedalboard's -persisted `rolling: true`, an external controller's transport button, or -a footswitch the *user* chooses to MIDI-learn onto the `:rolling` -global port. That last option is available but not required and not -something pi-Stomp drives on its own. - -**This is why no recording-order constraint exists.** Any of the three -tracks can be recorded first, second, or third — each one independently -locks to the same absolute beat grid the moment it's recorded, because -the grid was established by the shared broadcast tempo, not derived -from whichever track happened to go first. This directly matches what -we saw Marc do: 4 bars then 16 bars, either could have gone first, both -land cleanly on the grid. - -## Forking to `loopjefe-lv2`: identity changes - -Because this changes real-time *behavior* under an existing LV2 URI -(LV2 URIs are meant to be permanent, stable contracts), we're not -patching `mod-audio/sooperlooper-lv2-plugin` in place. The repo has -been cloned and renamed to `loopjefe-lv2` at `~/dev/loopjefe-lv2`, git -remote `github.com:sastraxi/loopjefe-lv2` (the canonical build source -for now; to be moved to a TreeFallSound org repo later — the debpkg's -`config.sh` ref will need updating at that point). Renaming the repo -alone isn't enough — every place the old plugin's identity is baked in -needs to change too, or the built `.lv2` bundle will still silently -collide with (or shadow) the original `sooperlooper.lv2` on-device. - -| Location | Current value | What needs to happen | -|---|---|---| -| `loopjefe/src/loopjefe.cpp:43` | `#define PLUGIN_URI "http://treefallsound.com/plugins/loopjefe"` | **Decided:** new URI namespace is `treefallsound.com/plugins`. Mono → `http://treefallsound.com/plugins/loopjefe`, stereo → `http://treefallsound.com/plugins/loopjefe-2x2`. This is the one change that actually matters for LV2 identity/compatibility; everything else below is bookkeeping around it. | -| `loopjefe/src/manifest.ttl` | Subject, `lv2:binary`, `rdfs:seeAlso` | Subject/binary/seeAlso filenames updated to match the renamed `.so`/`.ttl` (see directory renames below). | -| `loopjefe/src/loopjefe.ttl` | Plugin URI subject, `doap:name "LoopJefe"`, `mod:brand "MOD"` | `mod:brand "MOD"` dropped or changed to `"TreeFallSound"` — this isn't a MOD Devices-maintained plugin, keeping their brand label on it is misleading. | -| Directory names `loopjefe/`, `loopjefe-2x2/` | — | (Renamed from `sooperlooper/`, `sooperlooper-2x2/`.) The `Makefile` derives the plugin/bundle/binary name from `basename $(pwd)`, so the bundle name (`loopjefe.lv2`/`loopjefe-2x2.lv2`) cascades automatically. | -| Filenames `loopjefe.cpp`, `loopjefe.ttl` | — | (Renamed from `sooperlooper.cpp`, `sooperlooper.ttl`.) Not load-bearing (Makefile globs `src/*.cpp`), but renamed for clarity/consistency. | -| GPL copyright header (`loopjefe.cpp:1-27`) | `Copyright (C) 2002 Jesse Chappell` | **Preserved, not stripped** — this is GPL-licensed code and we're distributing a modified version. Fork/modification notice added alongside the original copyright; not replaced. `COPYING` (the GPL license file) stays as-is. | -| `modgui.ttl` (shipped separately, on-device only, not in this repo) | Points at the old `sooperlooper` URI | Needs its own new file pointing at the new URI, or the pretty pedal skin won't attach to the new plugin at all (it'll just fall back to mod-ui's generic control list — functionally fine, not blocking for v1). | -| `README.md` | — | States plainly: fork of `mod-audio/sooperlooper-lv2-plugin`, itself derived from Jesse Chappell's original SooperLooper (GPL), modified by TreeFallSound/pi-Stomp to add beat-synced multitrack recording. **Shipped.** | -| `lv2:minorVersion`/`lv2:microVersion` (`loopjefe.ttl`) | `0` / `1` | Reset from upstream's `0`/`9` — a version lineage that's ours, not a continuation of upstream's numbering. | - -The `-2x2` (stereo) variant gets the identical treatment, -independently (it's a fully separate `PLUGIN_URI`/directory/bundle, not -derived from the mono one) — same namespace, `-2x2` suffix as shown -above. - -## Plugin ports and the cycle/reset design - -The plugin is the LADSPA→LV2 SooperLooper shim with three new things -added in `loopjefe-lv2`: - -| Port | Type | Purpose | -|---|---|---| -| `time_info` | `atom:AtomPort`, `atom:Sequence`, `atom:supports time:Position` | Receives mod-host's per-block transport broadcast (frame, bar, barBeat, bpm, speed) — no pi-Stomp involvement, no mod-ui wiring. | -| `state` | `lv2:ControlPort`, `lv2:integer`, `lv2:enumeration` (5 scalePoints: Empty, Recording, Overdub, Playback, Stopped) | **Replaces** the old `play_pause`+`record` pair. Writes from any source (footswitch CC, plugin-internal, mod-ui REST) cycle the state machine; the plugin echoes its current state back via `param_set`, which pi-Stomp mirrors to the bound footswitch's LED. See "State machine" below. | -| `reset` | `lv2:ControlPort`, `lv2:integer` | Momentary trigger: when set non-zero, the plugin returns to `Empty` (state 0) and writes 0 back. Bound to the footswitch's longpress CC. | - -`time_info` is the plugin's first atom/URID code. The shim's -`instantiate()` previously ignored its `features` argument; adding -`time_info` also means: request and store `LV2_URID_Map` from `features` -in `instantiate`; map the `time:Position`, `time:frame`, `time:barBeat`, -`time:beatsPerMinute`, `time:speed` URIDs; add the `connect_port` case -for the new port; and walk the atom sequence each `run()` (manual -`LV2_ATOM_SEQUENCE_FOREACH` / object-property iteration — no forge -needed on the read side). Reference implementations to mirror: the -LV2 book's `eg-metro` example (canonical minimal `time:Position` -consumer) and any DPF/MOD tempo-synced plugin on-device. - -### State machine (plugin-owned, 5 states) - -The plugin's internal `SooperLooper::state` is a per-sample state -machine inherited from the LADSPA original. With the `state` port -exposed, the cycle a single footswitch drives is: - -| From state | Press | To state | Notes | -|---|---|---|---| -| Empty | press | Recording | Start quantizes to next downbeat (see Architecture) | -| Recording | press | Overdub | Length snaps to nearest whole bar | -| Overdub | press | Playback | | -| Playback | press | Stopped | Content retained, not playing | -| Stopped | press | Overdub | Re-enter overdub from the same content | - -The key design decision: **the plugin, not pi-Stomp, owns this state -machine.** The footswitch is dumb — it sends a single CC on every -press. The plugin reads `state` from the port, sees that the value -changed externally (CC wrote a non-zero value, or just *any* value -different from what the plugin itself last wrote), advances the state, -and writes the new value back. mod-host echoes the write as -`param_set /graph/loopjefe_1 state N`, mod-ui relays it over WS, and -pi-Stomp's existing `plugin.set_param_value("state", N)` calls -`set_value(N)` on the bound footswitch — which the footswitch uses -to color its LED (Empty=unlit, Recording=red, Overdub=yellow, -Playback=green, Stopped=dim green). - -This is the load-bearing simplification: pi-Stomp has zero per-track -state-machine code. The state lives in one place (the plugin), the -echo carries it to the LED, and the footswitch's existing -`param_set → set_value` chain does the work without any new -plumbing. Same path works for any external MIDI source (keyboard pads, -external foot controllers) bound to the `state` port in mod-ui. - -**The `state` port's value-echoing contract** (the reason this design -works): mod-host emits `param_set /graph/ ` -over its feedback socket for *any* control port value change, whether -the change came from a MIDI-learned CC, a mod-ui REST/WS command, or -the plugin writing to it in `run()`. mod-ui relays verbatim. pi-Stomp's -existing `ParamSetMessage` handler in `modalapi/plugin.py:127` calls -`set_value(value)` on any bound controller. Confirmed: this chain -is fully generic, not `:bypass`-specific. - -### Trigger vs. echo on the same port - -The `state` port serves as both the cycle trigger (the footswitch CC -writes 127 to it) and the state publisher (the plugin writes the -new state back). The plugin distinguishes external from internal -writes by tracking the value it last wrote: a value read in `run()` -that differs from the last self-written value is an external trigger; -the plugin cycles and rewrites. A matching value means no trigger -happened this block. - -This is the same pattern as a tap-tempo button: the port goes 0 → -momentary value → 0, and the plugin reacts to the momentary value. - -### `reset` (longpress → clear) - -`reset` is a separate, simple integer port. The footswitch's longpress -fires a *second* CC (different from the short-press CC). Both are -MIDI-learned in mod-ui: short-press CC → `state`, longpress CC → -`reset`. Pressing long: CC=127 sent, mod-host writes 127 to `reset`, -plugin resets to `Empty` (state 0) and writes 0 back. The `state` -echo lights the footswitch to off. The `reset` echo is ignored (its -value has no meaning beyond the trigger). - -This requires a small pi-Stomp extension: the existing `longpress` -config in `pistomp/config.py` is a closed enum of pi-Stomp-internal -behaviors (`next_snapshot`, `previous_snapshot`, `toggle_bypass`, -`set_mod_tap_tempo`, `toggle_tap_tempo_enable`). It needs to grow -arbitrary "send this CC" actions, the same way short-press bindings -already target arbitrary CCs/parameters. **Deferred to a follow-up -PR** — the plugin can land first and be exercised with two separate -footswitches (one bound to `state`, one to `reset`) for testing. - -### What stays out of v1 - -- `cycle_beats`, `quantize`, `measure_count`, `sync_target` ports — - FREE+BEAT-quantize-to-the-bar is hardcoded, not configurable. -- Reverse, one-shot, multiply, insert, replace, - configurable-Loop-Sync/Measure/Quantize/Tempo-Sync modes. -- `modgui` skin update for the new state-machine UX (the old skin - still works against the new plugin; the new URI just needs the - modgui file repointed at it, which is on-device-only and not - blocking). - -## What gets MIDI-learned through MOD-UI - -Every control port on an LV2 plugin instance is automatically -learnable in mod-ui — bind any incoming MIDI CC or note to any port, -per instance, per pedalboard, entirely inside mod-ui's existing UI. -This was true before our changes and doesn't need new pi-Stomp code -to work. The live-performance bindings per track are: - -- `state` — single CC drives the entire cycle. Bound to a pi-Stomp - footswitch's short-press CC, or to a keyboard pad, or to anything - else speaking MIDI into mod-host. -- `reset` — separate CC clears the track. Bound to a pi-Stomp - footswitch's longpress CC, or to a dedicated pad. -- `dryLevel` — a natural target for an expression pedal or a - controller knob (blend-mode-adjacent). - -Two independent sources can feed these learned bindings, and they can -coexist: - -1. **pi-Stomp footswitches**, which already emit raw MIDI CC - (alternating 0/127 for toggle-style presses). No plugin awareness - needed on pi-Stomp's side; it just sends CC, and mod-ui's learned - binding routes it to the right port. -2. **Any external MIDI device** — a keyboard's own pads/buttons, a - dedicated MIDI foot controller, anything speaking MIDI into - mod-host — bound the exact same way, entirely inside mod-ui, with - zero pi-Stomp involvement. A digital instrument's own loop-control - pads can be bound directly to the same `state` port pi-Stomp's - footswitches use, and multiple sources can be bound to the same - port simultaneously (e.g. both a footswitch and a keyboard pad - trigger `state` on track 2). - -## Live UX with pi-Stomp's 4 footswitches (3 tracks + metronome) - -Three tracks, one footswitch per track. The 4th switch is the -global metronome, reusing pi-Stomp's existing tap-tempo switch. This -discards the earlier "3 tracks + 1 modifier switch" design (with -chorded FSmod + FSn for clear and global-stop): the plugin's -5-state cycle makes a modifier switch unnecessary, and a single -footswitch per track is a much simpler learn. - -| Switch | Gesture | Bound to | Action | -|---|---|---|---| -| FS1/2/3 | short press | `state` (per track) | Cycle Empty → Recording → Overdub → Playback → Stopped → Overdub → … | -| FS1/2/3 | long press | `reset` (per track) | Clear the track back to Empty | -| FS4 | (n/a; metronome is a global visual) | — | Per-beat flash driven by the beat-grid sync (see below) | - -Two of the three real gesture tiers pi-Stomp's footswitches already -support are used: - -1. **Single (short) press** — release before 500ms. Fires immediately, - zero added latency. This is the tier every latency-sensitive, - frequently-hit action lives on. -2. **Long press** — held ≥500ms. Fires a `longpress_callback`. Today - the *set* of valid long-press actions is a fixed enum - (`pistomp/config.py`); to support the looper's longpress-as-clear, - this needs to grow arbitrary CC/parameter targets. **Deferred to a - follow-up PR.** -3. **Chord** — long-press-based simultaneity. Not used by the looper - (the modifier-switch design was removed). The chord machinery - stays for non-looper features (`next_snapshot`, `previous_snapshot`, - `toggle_bypass`, `toggle_tap_tempo_enable`). - -## Beat-grid sync: crossing the RT barrier (transport slave) - -pi-Stomp drives a synced metronome LED from the same beat grid the -plugin uses for its bar-quantize — with no audible click available -(the v3 codec has one shared stereo DAC; the "headphone" and "line" -jacks carry the *same* signal, so we cannot route a click to -phones-only like an RC-505; `mod-ui`/`mod-host` have no built-in -metronome). **The LED is the only feedback, so timing accuracy -matters and drift is the enemy.** - -### The clock-domain problem (and JACK's answer) - -The Pi's CPU clock and the codec's audio-sample clock are **different -crystals** and drift against each other. JACK's frame counter runs on -the *audio* clock; pi-Stomp schedules on the *system* clock. The bridge -is a real JACK primitive: **`jack_frames_to_time(client, frame)` → -`jack_time_t` (µs)**, backed by JACK's delay-locked loop that -continuously maps audio-frames ↔ system-microseconds. mod-host, as -timebase master, knows *which frame* is a downbeat (from its BBT: -`pos.bar`/`pos.beat`/`pos.frame`/`frame_rate`); JACK knows *what -system-time* that frame maps to. Neither pi-Stomp nor mod-ui has to -track consumption timing. - -### Design: mod-host emits absolute-timestamped downbeats over WS - -A pi-Stomp-side JACK client was rejected: it would drag the main -loop toward RT scheduling and the Python `jack` binding's -`transport_query()` **crashes** on-device — `unique_1 != unique_2` -ABI mismatch then a `munmap` abort, verified 20/20; the binding's -cdef also lacks `jack_frames_to_time`/`get_time`/`get_latency_range`, -so the frame→time conversion *must* happen in C anyway. Touches three -vendored repos, each additively: - -1. **mod-host (C, RT-safe):** each process cycle, from the `pos` it - already computes, detect a **downbeat** (bar-1) boundary crossing. - Convert that boundary frame to an absolute system-time and push - `{bar, t_us, bpm, beatsPerBar}` through the **existing - `rtsafe_memory_pool` postponed-event queue** (the same RT→non-RT - pipe used for `param_set` feedback — no new RT machinery). Emit - **on the downbeat only** (not every beat): one anchor + BPM lets the - consumer extrapolate the whole grid, so worst-case re-anchor - interval is one bar. The timestamp is in **`CLOCK_MONOTONIC`** in - microseconds, back-dated to the actual downbeat frame (not "now") - so consumer phase is immune to feedback/relay/WS-drain latency. -2. **mod-ui (Python):** relay as a new WS line `beat_sync - ` on the socket pi-Stomp already drains. -3. **pi-Stomp (Python):** parse (small `ws_protocol.py` add). On - receipt, `t_us` is the exact monotonic time the downbeat occurred. - Anchor the grid; drive the LED against - `time.clock_gettime(CLOCK_MONOTONIC)`. **No JACK client, no - subprocess, no RT** on pi-Stomp. - -**Why the absolute timestamp is the whole trick:** a "downbeat happened -NOW" message would be smeared by WS + relay + the 10ms drain. An -absolute `t_us` is immune — however late pi-Stomp reads it, -`phase = now − t_us` recovers the true elapsed time. Transport jitter -only delays when pi-Stomp *first learns* the grid, never the phase it -computes. - -### Audio-interface latency (answering "does frames_to_time handle it?") - -**No.** `jack_frames_to_time` maps a frame to system-time at JACK's -engine/driver boundary (anchored to the ALSA hardware IRQ). It does -**not** add the playback-port buffering + codec latency between that -boundary and the physical output jack. Here that residual is -period×nperiods = 64×2 = 128 frames ≈ **2.67 ms**, plus a few frames -of DA7212 codec latency ≈ **~4–5 ms total**. That is far below the -~20–40 ms audio-visual sync threshold the eye can detect, so the -correction is **optional, not critical** — the loops themselves are -the only audible reference and 5 ms against them is imperceptible. If -we want exactness anyway it's cheap: add -`jack_port_get_latency_range(out, JackPlayback)` (µs) to `t_us` in -mod-host. **Drift, not this constant offset, is the thing that -actually matters** — and the per-downbeat re-anchor bounds drift to at -most one bar. - -## LED feedback - -### Metronome (FS4, delivered) - -FS4 (the tap-tempo switch) is the global visual metronome. It's -driven by the beat-grid anchor: a short **~80 ms flash per beat** -(≈8 main-loop ticks at 100 Hz), with the downbeat flashed brighter. -Concretely, the LED strip pixel and the GPIO LED on FS4 are both -flashed for 80ms after each beat boundary crossing, with the strip -color at full white (255, 255, 255) on a downbeat and a slightly -dimmer white (180, 180, 180) on regular beats — the two different -RGB values render as visually distinct whites against the strip's -global `LED_BRIGHTNESS = 0.19`. The GPIO LED has no per-channel -brightness, so it just goes on for both. - -When the transport stops, mod-ui sends `transport 0 …`, which pi-Stomp -uses to clear the grid immediately. The 5-second stale timeout in -`BeatGrid` is a safety net for a crashed mod-host that produces no -events at all. With no anchor, the metronome driver leaves the -pixels off; the taptempo's own set_led path (or the next category -update) restores FS4's normal state. - -### Per-track state color (FS1/2/3, deferred to plugin-side PR) - -Each track's LED should mirror the plugin's `state` value, per the -RC-505 track-button convention: - -| LED | State | -|---|---| -| Unlit | Empty (no phrase) | -| Red | Recording | -| Yellow | Overdubbing | -| Green | Playing | -| Dim green | Stopped-but-has-content | - -The LED color is **derived from the enumeration's scalePoint labels**, -not from a hardcoded value→color table. The plugin's TTL declares the -`state` port with `lv2:enumeration` and 5 scalePoints whose `rdfs:label` -is one of the conventional names: - -``` -:state a lv2:InputPort, lv2:ControlPort ; - lv2:integer ; - lv2:enumeration ; - lv2:scalePoint [ rdfs:label "Empty" ; ] ; - lv2:scalePoint [ rdfs:label "Recording" ; ] ; - lv2:scalePoint [ rdfs:label "Overdub" ; ] ; - lv2:scalePoint [ rdfs:label "Playback" ; ] ; - lv2:scalePoint [ rdfs:label "Stopped" ; ] . -``` - -The label→color mapping lives in the **plugin customization** system -(`modalapi/plugin_customization.py` + `plugins/customization.py`), -which is the right home for "this plugin's bound parameters have a -non-trivial value→LED-color mapping." A new optional field on -`PluginCustomization`: - -```python -# modalapi/plugin_customization.py -LedColorFn = Callable[[Footswitch, float], tuple[int, int, int] | None] - -@dataclass(frozen=True) -class PluginCustomization: - panel_cls: type[PluginPanel] | None = None - display_name: str | None = None - display_name_fn: Callable[[Plugin], str | None] | None = ... - subtitle_fn: Callable[[Plugin], str | None] | None = ... - intercept_shortpress: bool = False - tile_active_color: tuple[int, int, int] | None = None - tile_border: RectBorder | None = None - extra_data: PluginExtraData | None = None - footswitch_led_color_fn: LedColorFn | None = None # NEW -``` - -The function is called from `Footswitch.set_led` whenever a -`footswitch_led_color_fn` is set on the bound plugin's customization; -it returns an `(r, g, b)` triple for "lit in this color" or `None` -for "unlit." If the customization isn't set, `set_led` falls through -to the existing category-color logic (`Pixel.set_color_by_category` -+ on/off), so all existing plugins (and `:bypass`-bound footswitches) -behave exactly as today. - -The loopjefe customization is then a small package under -`pi-stomp/plugins/loopjefe/`: - -```python -# pi-stomp/plugins/loopjefe/__init__.py -from modalapi.plugin import Plugin -from modalapi.plugin_customization import PluginCustomization -from plugins.customization import register -from pistomp.footswitch import Footswitch - -LOOPJEFE_URIS = ( - "http://treefallsound.com/plugins/loopjefe", - "http://treefallsound.com/plugins/loopjefe-2x2", -) - -_LABEL_TO_COLOR = { - "Empty": None, # unlit - "Recording": (255, 0, 0), - "Overdub": (255, 255, 0), - "Playback": ( 0, 255, 0), - "Stopped": ( 0, 80, 0), # dim green -} - -def _state_to_color(footswitch: Footswitch, value: float) -> tuple[int, int, int] | None: - param = footswitch.parameter - if param is None or not param.enum_values: - return None - for scale_point in param.enum_values: - if float(scale_point.get("value", 0)) == value: - return _LABEL_TO_COLOR.get(scale_point.get("label", "")) - return None - -register( - *LOOPJEFE_URIS, - customization=PluginCustomization(footswitch_led_color_fn=_state_to_color), -) -``` - -The value arriving via `param_set` is converted to a color in three -steps: `value → scalePoint (via enum_values lookup) → label -(scalePoint.rdfs:label) → color (_LABEL_TO_COLOR[label])`. A -scalePoint whose label isn't in the map returns `None` (unlit) and -the footswitch falls back to its existing category color. **The -plugin author controls what the LED shows by choosing scalePoint -labels** — not by being known to pi-Stomp via a URI prefix or a -hardcoded value table. Any plugin that declares a `state`-like port -with the conventional labels will get the right colors without -further pi-Stomp changes (just register a customization). - -**Generalizes beyond `:bypass`.** The function takes -`(footswitch, value)` — it doesn't care which port. Today's -`param_set → Footswitch.set_value → Footswitch.set_led` chain runs -for any MIDI-mapped parameter; the customization just gets to -override the LED color logic. For `:bypass` (binary; the category -color is fine) the customization simply isn't set, and the existing -logic applies unchanged. For multi-state ports like loopjefe's -`state`, the customization takes over. A plugin that wanted a -gradient-color display for a continuous parameter could register a -customization that maps `(lo, value, hi)` to a color — same hook, -no new infrastructure. - -The wiring on the param_set side already exists: when a -`param_set /graph/loopjefe_1 state 2.0` arrives, `plugin.set_param_value` -in `modalapi/plugin.py:127` calls `set_value(2.0)` on the bound -footswitch controller, which calls `set_led(...)`, which consults -the customization. The deferred work is therefore small: add the -new customization field, plumb a back-reference from `Footswitch` -to the bound `Plugin` (set in `_bind_controller_to_param`), and -update `set_led` to call `footswitch_led_color_fn` before the -default path. A `param_set … state` echo with the right scalePoint -labels will already work correctly today (the existing `set_value` -ignores the value if the parameter's type is `Type.ENUMERATION`, -but the enumeration's scalePoint labels are already parsed and -available via `Parameter.get_enum_value_list()`). What is *not* -done is the customization hook in `set_led` and the loopjefe -customization itself — both small. Until the loopjefe plugin -exposes `state` (deferred), this is moot. - -### Count-in pulse (was speculative, removed) - -The earlier plan called for "pulsing red during a record-pending -window" — visual substitute for RC-505's audible 1-measure count-in -that we can't reproduce (single shared DAC, no phones-only bus, no -mod metronome). With the plugin owning the state machine and the -press-to-record gap being at most one bar (~1-2s at typical tempos), -the visual transition is now: green (Stopped) → brief black → red -(Recording). The plugin goes from Stopped to Recording on the next -downbeat, and the LED is driven by the `state` echo. The -"pulsing red during the gap" was tied to pi-Stomp's pre-state-machine -design; it's removed. - -## What changes when more MIDI is available - -Everything above still applies — footswitches are just one MIDI source -among possibly several bound to the same ports. More MIDI availability -buys: - -- **More tracks addressable without a modifier switch.** A keyboard - with enough pads can bind directly to N tracks' worth of `state` - ports, no chord addressing needed at all. -- **Real secondary actions without the 500ms+ long-press cost.** Undo, - redo, per-track dry level — each gets its own dedicated pad or - knob instead of being squeezed into chords. -- **Expression control** — a pedal or mod wheel bound to `dryLevel` - live, which is the one place pi-Stomp's "blend mode" is explicitly in - scope to help coordinate. -- Bass/keys/pads on the same device don't have to be sacrificed for - loop control — with enough MIDI surface, looping and playing can be - simultaneous and physically separate, which footswitches alone can't - offer since your feet are busy but so is everything else. - -## Plugin state-machine touchpoints (`loopjefe/src/loopjefe.cpp`) - -The engine is a per-sample state machine (`pLS->state`) inherited from -the LADSPA original. The five `STATE_*` values reachable from the new -`state` port (the rest are dead code with no path that sets -`state` or `nextState` to them): - -| State | # | Meaning | -|---|---|---| -| `STATE_OFF` | 0 | Empty (no phrase) | -| `STATE_TRIG_START` | 1 | armed, waiting to begin recording — **bar-start quantize injection point** | -| `STATE_RECORD` | 2 | actively recording | -| `STATE_PLAY` | 4 | playing the loop | -| `STATE_OVERDUB` | 5 | overdubbing | - -(`STATE_TRIG_STOP` (3) and `MULTIPLY`/`INSERT`/`REPLACE`/`DELAY`/ -`MUTE`/`SCRATCH`/`ONESHOT` (6-12) are dead code — no control path ever -sets `state` or `nextState` to any of them.) - -`STATE_OFF` here is "empty," not "stopped." The 5-state UX described -in the table above is implemented as a thin wrapper that maps the -`state` port's value to the internal `pLS->state` transitions: Empty -↔ Recording ↔ Overdub ↔ Playback ↔ Stopped ↔ Overdub. "Stopped with -content" and "Empty" are externally distinct but internally the -plugin returns to `STATE_PLAY`/loops; the "stopped" surface state is -maintained by the wrapper so the LED reflects it. - -Touchpoints: -- **`state` port write → cycle**: setting `state` to a value different - from the last plugin-written value advances the state machine and - writes the new value back. The 5-state cycle is implemented in the - wrapper; the underlying `STATE_*` transitions are unchanged from the - earlier free-form `play_pause`/`record` design. -- **Start quantize** (`STATE_TRIG_START` handler): same as before — - computes the sample offset to the next bar boundary from the cached - transport (`barBeat`, `beatsPerBar`, `beatsPerMinute`), reuses the - existing block-local state-transition idiom (advance `lSampleIndex` - to the trigger sample, push the new loop chunk, set `state = - STATE_RECORD`, then `break` so the outer `while` loop immediately - re-dispatches into the `STATE_RECORD` case for the remainder of - the block — sample-accurate, no dead time). Falls back to - triggering immediately when the transport isn't rolling. -- **Length finalize on stop**: the real "stop recording" event is the - `*(plugin->record) <= 0.0 && plugin->recording` branch in `run()`'s - control-reading section (not `STATE_TRIG_STOP`, which is - unreachable). Round-to-nearest-bar is applied there, gated on - `pLS->state == STATE_RECORD` so overdub stops (which inherit their - source loop's length verbatim) are left alone. The `state`-port - transition from Recording → Overdub happens here. -- **`reset` port write → Empty**: any non-zero value on `reset` - triggers a hard reset of the loop's audio content and - `state`-wrapper variable back to `STATE_OFF` / `Empty`. - -Both `loopjefe/` and `loopjefe-2x2/` carry a full copy of this file — -every change is applied twice (the dirs are independent bundles, not -shared code). The stereo variant's `STATE_TRIG_START` steps its outer -sample loop by `NUM_CHANNELS` while indexing input frames directly by -that same stepped index, inconsistent with `STATE_RECORD`'s -per-sample loop just below it — pre-existing, left as-is; the -bar-boundary trigger reuses the same loop shape the amplitude trigger -it replaced had. - -### How to verify (before touching pi-Stomp) - -The plugin is fully testable via mod-ui MIDI-learn, no pi-Stomp code: - -1. Build locally: `make` at repo root produces `loopjefe.lv2`/ - `loopjefe-2x2.lv2`. Install to a MOD host (MOD Desktop, or scp to - the device's `~/.lv2/`). -2. Load a pedalboard with the plugin. Set transport **rolling** with - a known BPM/`beatsPerBar` (persist `timeInfo.rolling: true` or hit - play). -3. MIDI-learn a CC (any source) to `state`. -4. **State echo:** press the footswitch. The plugin's `state` value - should advance (0 → 1 → 2 → 3 → 4 → 2 → 3 → 4 → …) and be - reflected in the mod-ui plugin UI. -5. **Start quantize:** trigger `state` at a random moment mid-bar; - confirm the Empty → Recording transition lands on the next - downbeat, not instantly. Watch the loop-length readout — first - pass may need `fprintf` debugging of the computed boundary frame. -6. **Length quantize:** record for ~3.5 bars then trigger the - Recording → Overdub transition; confirm the resulting loop length - is an integer number of bars (4), not 3.5. -7. **Multitrack lock (the real test):** on two instances, record a - 4-bar loop and a 16-bar loop in *either order*; confirm their - downbeats stay coincident over many repeats (this is the whole - point). -8. **Fallback:** stop the transport, trigger a transition; confirm - it free-runs (transitions immediately, unquantized) instead of - hanging. -9. **Reset:** trigger `reset`; confirm the track clears and the - `state` value goes back to 0. -10. Confirm the modgui still loads with the added `state` and `reset` - ports (or falls back to the generic control list without - erroring). - -## Packaging: `loopjefe-lv2` debian package (`pi-gen-pistomp`) - -Ships to devices via the existing apt-repo OTA flow, mirroring the -LV2 package pattern already used by `cabsim-lv2` / `veja-*-cab-lv2`: - -1. **`config.sh`** — add `LOOPJEFE_LV2_REPO` / `LOOPJEFE_LV2_REF` - alongside the existing `CABSIM_LV2_REPO/REF`. Repo = - `https://github.com/sastraxi/loopjefe-lv2`, ref = `main` (or a - pinned tag once cut). -2. **`debpkgs/loopjefe-lv2/`** — new package dir cloned from the - cabsim template: - - `build.sh` — clone the ref, `record_upstream_sha`, copy - `debian/` over, `dpkg-buildpackage -b -us -uc`, `move_to_cache`. - - `debian/control` — `Source: loopjefe-lv2`, `Build-Depends: - debhelper-compat (= 13), lv2-dev` (no fftw/sndfile — this - plugin has no such deps), `Architecture: arm64`. - - `debian/rules` — `override_dh_auto_build`/`install` calling the - repo's top-level `Makefile` (which recurses into both - `loopjefe/` and `loopjefe-2x2/`), installing both `.lv2` bundles - under `usr/lib/lv2/`. No modgui to copy for v1. - - `debian/changelog` — initial `loopjefe-lv2 (0.1-1) trixie` - entry. -3. **Ship it** — bump the package version via `bump-version.sh`, - push `pi-gen-pistomp#main`; `build-deb.yml` builds the `.deb` and - `publish-apt-repo.yml` updates the apt index (per CLAUDE.md's - "Shipping a new pi-stomp version" flow, same mechanism). - -Blocked only on the plugin building cleanly and the renamed/patched -tree being pushed to the build-source remote — this step comes last. - -## Open questions - -- Mid-song tempo *change* under measure-quantize: the plugin's bar - math and pi-Stomp's grid extrapolation both assume constant tempo - since the last anchor. Fine for the target workflow; revisit if - tempo automation is ever needed (JACK `bar_start_tick` handles it - authoritatively). -- Design B (explicit master→follower `AUTO`-style length mirroring) - is deferred, not rejected — worth revisiting if a workflow ever - needs one track's length forced to exactly match/multiply another's - rather than everything independently landing on the shared beat - grid. -- Plugin-side `state` port: should the value be 0-indexed (0..4) or - 1-indexed (1..5)? LV2 enumeration values are arbitrary; the - pi-Stomp LED mapping and the wrapper's internal transitions need to - agree. **Resolved for v1: 0-indexed** (Empty=0, Recording=1, - Overdub=2, Playback=3, Stopped=4) — easier to compute `bar * - bpb + beat_in_bar` style arithmetic. - ---- - -## Appendix: Delivered and remaining work - -A snapshot of the in-progress state across the three repos. Everything -listed as "delivered" is implemented in the working tree but -**uncommitted**; "deferred" is on the roadmap but not yet started. - -### `~/dev/loopjefe-lv2` (the plugin) - -| Item | Status | Notes | -|---|---|---| -| Identity rename (`sooperlooper*` → `loopjefe*`, new URI namespace) | **Delivered** | Uncommitted; staged in working tree | -| GPL copyright preservation + fork notice | **Delivered** | Header in `loopjefe.cpp` | -| `time_info` atom port + LV2_URID_Map wiring + `readTimeInfo()` | **Delivered** | Mono and `-2x2` variants | -| Bar-boundary quantize in `STATE_TRIG_START` | **Delivered** | Computes sample offset to next downbeat each block | -| Length snap to nearest whole bar on stop | **Delivered** | Gated on `pLS->state == STATE_RECORD` | -| Free-run fallback when transport stopped | **Delivered** | | -| `state` port (5-value enumerated integer) replacing `play_pause`+`record` | **Deferred** | Separate PR | -| 5-state cycle wrapper (`Empty`/`Recording`/`Overdub`/`Playback`/`Stopped`) | **Deferred** | Separate PR; the 4-state `STATE_*` internals are unchanged | -| `reset` port (longpress → clear) | **Deferred** | Separate PR | -| modgui skin update for the new URI | **Deferred** | On-device-only, not blocking for v1 | -| `debpkgs/loopjefe-lv2/` in `pi-gen-pistomp` | **Not started** | Blocked on plugin tree landing | - -### `~/dev/mod-host` - -| Item | Status | Notes | -|---|---|---| -| `POSTPONED_BEAT_SYNC` event type in `src/effects.c` | **Delivered** | Uncommitted; adds struct, event-type, and switch case | -| Downbeat crossing detection in `UpdateGlobalJackPosition` | **Delivered** | Back-dates `t_us` to actual downbeat frame via `bar_beat`/`beat_length_frames` math; reuses existing `rtsafe_memory_pool` | -| `g_last_beat_sync_bar` reset on stopped/no-BBT | **Delivered** | Lets a fresh roll re-anchor immediately | -| `clock_gettime(CLOCK_MONOTONIC)` for the anchor `t_us` | **Delivered** | Unconditionally correct; sidesteps any `jack_time_t`-vs-monotonic question | -| `jack_port_get_latency_range` correction (optional ~5ms offset) | **Not started** | Optional, not critical per the latency analysis | - -### `~/dev/mod-ui` - -| Item | Status | Notes | -|---|---|---| -| `beat_sync` relay in `process_read_message_body` | **Delivered** | Uncommitted; `mod/host.py` 7-line addition | -| Browser-side `ws.onmessage` handling | **N/A — no change needed** | `html/js/host.js` silently ignores unrecognized commands; `beat_sync` falls through the if/else chain without matching any branch, no crash, no error. `triggerDelayedReadyResponse(false)` clears the data-ready handshake timer (harmless, next `data_finish` re-arms it). | -| HMI-side handling | **N/A — no change needed** | The HMI WebSocket is inbound-only (HMI sends commands *to* mod-ui, doesn't receive broadcasts). | - -### `~/dev/pi-stomp` - -| Item | Status | Notes | -|---|---|---| -| `BeatSyncMessage` typed message in `ws_protocol.py` | **Delivered** | Uncommitted; one-line docstring: "`t_us` is back-dated to the actual downbeat frame." | -| `parse_message` case for `beat_sync` | **Delivered** | Uncommitted; matches the format `beat_sync ` | -| `pistomp/beatsync.py` (`BeatGrid` + `TickState`) | **Delivered** | Uncommitted; new file. `on_anchor` / `clear` / `tick`. Re-anchors on each `beat_sync`; ticks return `(is_anchored, is_flashing, is_bar_start, bpm, bpb)`. No docstrings — the field names carry the meaning. | -| `tests/test_beatsync.py` (19 tests) | **Delivered** | Uncommitted; covers anchor/tick/flash/clear/stale/re-anchor/frozen-dataclass behaviors | -| `tests/test_ws_protocol.py` (6 parser tests) | **Delivered** | Uncommitted; happy path, edge cases | -| Dispatch `BeatSyncMessage` / `TransportMessage(rolling=False)` in `_handle_ws_message` | **Delivered** | Uncommitted; `modhandler.py` | -| `_drive_metronome` in `poll_indicators` (FS4 strip + GPIO LED flash) | **Delivered** | Uncommitted; `modhandler.py`. Always-on (no pedalboard gate). Strip pixel: `(255,255,255)` on downbeat, `(180,180,180)` on regular beat. GPIO LED: `on()` for any flash. | -| `pyright` + `ruff` clean, 1019 tests pass | **Verified** | Zero regressions | -| Per-track state-cycle tracking (was plan item 1) | **Removed — moved to plugin** | Plugin owns the state machine. The 5-state `state` port echo drives the footswitch LED through the existing `param_set → set_value` chain. No pi-Stomp code needed. | -| Per-track LED state coloring (was plan item 2) | **Deferred to plugin-side PR** | Add a `footswitch_led_color_fn` field to `PluginCustomization` (in `modalapi/plugin_customization.py`), plumb a back-reference from `Footswitch` to the bound `Plugin`, and have `Footswitch.set_led` consult the customization before falling through to the existing category-color path. Then register the loopjefe customization at `pi-stomp/plugins/loopjefe/__init__.py` with a `LABEL → COLOR` map keyed off the `state` port's scalePoint labels (`Empty`/`Recording`/`Overdub`/`Playback`/`Stopped`). The customization hook is general — any plugin with a non-trivial value→LED-color mapping can register a function. See the "Per-track state color" section. | -| Longpress → secondary CC (was plan item 3) | **Delivered** | `longpress` now accepts `{midi_CC: N}` alongside the existing named-group form (string/list); `Footswitch.set_longpress` dispatches between them, and `Handler._handle_footswitch` sends a momentary CC=127 via a consolidated `_emit_midi(controller, value, cc=...)` on the shared `Handler` base (previously duplicated per-subclass). The looper's longpress-as-clear binding (short-press CC → `state`, longpress CC → `reset`) can now be configured directly in `config.yml`, no mod-ui workaround needed. | -| v1 mod.py (mono LCD, no ledstrip) support | **Explicitly out of scope** | v1 has no LED strip; the metronome LED has no v1 surface. The `BeatSyncMessage` parser still recognizes the message; v1's `_handle_ws_message` simply has no `isinstance(msg, BeatSyncMessage)` branch and ignores it. No code change needed in v1. | diff --git a/docs/plan-footswitch-behaviour.md b/docs/plan-footswitch-behaviour.md deleted file mode 100644 index 98eeaed85..000000000 --- a/docs/plan-footswitch-behaviour.md +++ /dev/null @@ -1,303 +0,0 @@ -# Plan: Generic plugin-owned footswitch behavior (LED + press semantics), loopjefe first consumer - -## Context - -pi-Stomp is gaining an RC-505-style multitrack looper (`loopjefe-lv2`) controlled -from footswitches. The plugin was redesigned and the earlier plan -(`docs/multitrack-looper-plan.md`) is now materially stale in two ways that break -pi-Stomp's assumptions: - -- **`state` is now a read-only OUTPUT control port with 9 states** (Empty=0, - Record Arm=1, Recording=2, Record Close=3, Playback=4, Stopped=5, Overdub - Arm=6, Overdub=7, Overdub Close=8). Output ports reach pi-Stomp as `output_set`, - **not** `param_set` — and pi-Stomp currently drops *all* `output_set` at the WS - bridge (`websocket_bridge.py:207`, an audio-meter flood). So the old - "`param_set` echo → footswitch LED" path no longer fires for `state`. -- **Control is via edge-triggered, self-clearing input trigger ports**: `advance` - (short-press), `reset` (long-press), plus `undo`/`redo`. pi-Stomp's short-press - today alternates `127/0` (`handler.py:168-170`), so every other press sends `0` - — a dead no-op for a rising-edge trigger. The self-clearing behavior is - documented in `loopjefe-lv2/docs/state-machine-redesign.md`, `CLAUDE.md`, - `src/state_machine.h` (implementation), and tested in - `tests/test_state_ports_contract.cpp`. `undo`/`redo` are NOT self-clearing - (the host must clear them). - -Rather than hardcode looper knowledge into the handler, the goal is a **generic -plugin-customization mechanism**: when a footswitch is MIDI-bound (via mod-ui -MIDI-learn, reflected in the pedalboard TTL) to a plugin's parameter, pi-Stomp -looks up that plugin's registered `PluginCustomization` and lets it own the -footswitch's behavior — press semantics (momentary vs toggle), which of the -plugin's OUTPUT ports to subscribe to, and per-tick LED rendering (color as a -function of cached state, beat-synced pulsing). Any future plugin with a -non-trivial control surface registers a customization with **zero new handler -code**. - -The FS4 `beat_sync`→`BeatGrid` metronome already works and is unaffected; this -plan generalizes the *per-track* footswitch behavior and adds the `output_set` -plumbing it needs. Confirmed product decisions: LED color is a pure function of -the 9-state value (matching loopjefe's MODGUI, **not** per-track colors); all -active states beat-pulse (bright on beat, brightest on the bar downbeat, dim -between), including the blue shoulder states; Stopped is steady grey; Empty is -off. - -## Key facts established during exploration - -- **Footswitch → Parameter back-ref exists** (`Controller.bind_to_parameter` sets - `self.parameter`, `controller.py:89`), giving `fs.parameter.instance_id` + - `fs.parameter.symbol`. **Footswitch → Plugin does NOT exist** — only the reverse - `plugin.controllers.append(controller)` (`controller_manager.py:90`). We do NOT - add a forward ref; the behavior factory `footswitch_behavior_fn(plugin)` receives - the plugin and captures whatever it needs (instance_id, customization fields) on - the behavior object itself. -- **Every footswitch always has a behavior.** `ControllerManager.bind()` attaches - either a plugin-provided behavior or a `DefaultFootswitchBehavior` (toggle - semantics, category color, no WS subscriptions). This eliminates all `None` - guards on `fs.behavior` in the driver and dispatch code. -- **`set_led()` and `set_category()` no longer drive the pixel.** The old baked-in - behavior (`set_led` called `pixel.set_enable`, `set_category` called - `pixel.set_color_by_category`) was removed. Pixel rendering is now entirely - owned by `_drive_footswitch_leds` in `poll_indicators`. `set_led` only handles - the GPIO LED (for immediate press feedback); `set_category` only stores the - category for the default behavior to read. -- **Binding happens in `ControllerManager.bind()`** (`controller_manager.py:67-101`), - called on every pedalboard/state change; loopjefe's `advance` port (MIDI-learned) - gets a TTL `midi:binding`, so the short-press footswitch binds to - `parameter.symbol == "advance"` on the loopjefe instance. -- **`PluginCustomization`** (`modalapi/plugin_customization.py:30`) is a frozen - dataclass looked up by URI (`plugins/customization.py` `register`/`lookup`) and - stored on `Plugin` at load (`pedalboard.py:228-237`). Existing consumers under - `plugins/*/__init__.py` (~30 packages). -- **LED surfaces**: `fs.pixel` (RGB `Pixel`, `ledstrip.py:51` — `set_color` stores, - `set_enable(True)` renders) and `fs.led` (GPIO on/off/blink). `set_led` - (`footswitch.py:115`) only toggles enable; color comes from `set_category` → - `Category.get_category_color`. -- **Per-tick LED driver today is only `_drive_metronome`** (`modhandler.py:343`), - hard-targeting the taptempo FS with hardcoded colors, called from - `poll_indicators` (~20ms). `BeatGrid.tick(now)` → `TickState(is_anchored, - is_flashing, is_bar_start, bpm, bpb)` (`beatsync.py`). This is the insertion - point for a generic per-footswitch driver. -- **WS receive**: `output_set ` frames dropped cheaply by prefix at - `websocket_bridge.py:207` (on the `WebSocketWorker` thread) *before* parsing. - `param_set /graph/ ` and `output_set /graph/ ` - share shape. No `OutputSetMessage` type exists; no subscription concept exists. - `_handle_ws_message` (`modhandler.py:531`) is an isinstance ladder; ParamSet is - matched to a plugin by O(n) scan on `instance_id`. - -## Design overview - -Introduce a **`FootswitchBehavior`** — a per-binding object a plugin's -customization creates at bind time and attaches to the bound footswitch. It owns: - -1. **Press semantics** — `momentary: bool` (short-press sends a one-shot `127` - instead of toggling). Checked by `_handle_footswitch`; no separate - `momentary_symbols` field needed on `PluginCustomization`. -2. **Output subscriptions** — `output_subscriptions() -> Iterable[str]`: symbols - on *this instance* whose `output_set` the behavior wants (e.g. `["state"]`). -3. **Value intake** — `on_output(symbol, value)` caches incoming state. -4. **Per-tick color** — `led_color(beat: TickState) -> tuple[int,int,int] | None`: - returns the color for this tick (None = off). The behavior never computes - brightness — that's the driver's job. -5. **Per-tick style** — `led_style(beat: TickState) -> LedDisplayStyle`: returns - `SOLID` or `METRONOME` for this tick. Defaults to `SOLID`. - -`PluginCustomization` grows one optional field: -- `footswitch_behavior_fn: Callable[[Plugin], FootswitchBehavior] | None = None` - (stored with `compare=False, hash=False` like the other `*_fn` fields). When set, - the factory is called at bind time; when unset, a `DefaultFootswitchBehavior` is - used instead. Every footswitch always has a behavior — no `None` guards needed. - -The WS bridge gains an **atomically-swappable interesting-set** of -`"instance/symbol"` keys; subscribed `output_set` frames survive the prefix drop, -flow through a new `OutputSetMessage`, and are dispatched to the owning behavior. -De-registration is automatic — the interesting-set is fully recomputed on every -`ControllerManager.bind()` call (pedalboard load/rebind), and the old set is -atomically swapped out. - -A new **`_drive_footswitch_leds(beat)`** in `poll_indicators` computes the beat -tick once and asks every behavior-bearing footswitch to render. - -## Changes by area - -### 1. Momentary short-press (standalone, required) - -- `pistomp/handler.py` `_handle_footswitch` short-press arm (`~167-170`): if - `fs.behavior.momentary`, emit `_emit_midi(fs, 127)` every press (no `toggled` - flip). Otherwise keep the existing toggle behavior. No plugin back-ref needed - — the behavior carries `momentary`. -- Longpress already sends momentary `127` (`handler.py:152`) — unchanged; covers - `reset`. - -### 2. Behavior attachment (every footswitch always has a behavior) - -- `pistomp/footswitch.py`: add `self.behavior: "FootswitchBehavior | None = None` - in `__init__`; clear it in `clear_pedalboard_info` (`footswitch.py:187`). - `set_led()` no longer drives `fs.pixel`; `set_category()` no longer calls - `pixel.set_color_by_category`. Pixel rendering is entirely owned by the - per-tick driver. -- `pistomp/controller_manager.py` `bind()` (`~85-90`, the `isinstance(controller, - Footswitch)` block): if `plugin.customization.footswitch_behavior_fn` is set, - call it; if it returns a behavior, attach it; otherwise fall back to - `DefaultFootswitchBehavior(controller)`. -- Type-only import of `FootswitchBehavior` (use `TYPE_CHECKING` / string - annotations). - -### 3. `FootswitchBehavior` protocol + `DefaultFootswitchBehavior` + `LedDisplayStyle` - -- New `modalapi/footswitch_behavior.py`: - ```python - class LedDisplayStyle(Enum): - SOLID = auto() # steady color (default) - METRONOME = auto() # driver pulses brightness on the beat grid - - class FootswitchBehavior(Protocol): - @property - def momentary(self) -> bool: ... - def output_subscriptions(self) -> Iterable[str]: ... - def on_output(self, symbol: str, value: float) -> None: ... - def led_color(self, beat: TickState) -> tuple[int, int, int] | None: ... - def led_style(self, beat: TickState) -> LedDisplayStyle: ... - - class DefaultFootswitchBehavior: - """Built-in: toggle semantics, category color, no WS subscriptions.""" - def __init__(self, fs: Footswitch) -> None: ... - ``` - **Key separation:** the behavior returns *what color + which style*, never the - per-tick brightness. The generic driver (§5) owns the metronome gradient. A - behavior varies only its returned *color* over time (e.g. loopjefe returns a - distinct color when `measure_number == 0`). No `LedRender` dataclass — two - methods are cleaner. -- Add `footswitch_behavior_fn` to `PluginCustomization` - (`modalapi/plugin_customization.py:30`). - -### 4. `output_set` subscription filter + typed message + dispatch - -- `modalapi/websocket_bridge.py`: - - Add `self._interesting: frozenset[str] = frozenset()` and - `set_interesting_outputs(keys: frozenset[str])` (atomic reference swap — no - lock needed for replace + membership read under the GIL). - - In `_receive_messages` (`~207`), replace the unconditional `output_set ` drop - with: cheap `split(" ", 3)`, build key `inst_no_prefix + "/" + symbol`, keep - only if in `self._interesting`, else `continue` (drop as today). -- `modalapi/ws_protocol.py`: add `OutputSetMessage(instance, symbol, value)` - dataclass + a `case ["output_set", path, rest]` in `parse_message` (mirror the - `param_set` arm at `:290`), add to the `WebSocketMessage` union. -- `modalapi/modhandler.py`: - - After `ControllerManager.bind()` (pedalboard load/rebind), assemble the - interesting-set by scanning footswitches with behaviors: - `{f"{fs.parameter.instance_id}/{sym}" for fs in ... for sym in - fs.behavior.output_subscriptions()}` and push via - `ws_bridge.set_interesting_outputs(...)`. - - Add an `isinstance(msg, OutputSetMessage)` arm to `_handle_ws_message` - (`~531`): find footswitch(es) whose behavior stores a matching `instance_id` - (captured at construction from the plugin), call - `behavior.on_output(msg.symbol, msg.value)`. - -### 5. Generic per-tick LED driver - -- `modalapi/modhandler.py` `poll_indicators` (`338`): compute - `beat = self.beat_grid.tick(_now_us())` **once**; pass it to the existing - `_drive_metronome(beat)` (FS4) and to a new `_drive_footswitch_leds(beat)`. -- `_drive_footswitch_leds(beat)`: for each `fs` in `hardware.footswitches`, call - `color = fs.behavior.led_color(beat)` and `style = fs.behavior.led_style(beat)`. - Every footswitch always has a behavior — no `None` guard needed. - - `SOLID` → show `color` steadily (or off when `color is None`). - - `METRONOME` → the driver scales `color` brightness by the beat envelope - (below), so the pulse is uniform across all metronome LEDs regardless of - plugin. When the grid is unanchored (`not beat.is_anchored`) → show `color` - steadily (no pulse). - Apply to `fs.pixel` (`set_color(scaled); set_enable(True)` / `set_enable(False)` - when `None`) and `fs.led` (`on()`/`off()`), mirroring `_drive_metronome`. -- **Beat envelope / gradient.** To make `METRONOME` a real gradient (not a square - 80ms flash), extend `BeatGrid.tick`/`TickState` (`pistomp/beatsync.py`) to expose - a normalized within-beat phase (e.g. `beat_phase: float` in `[0,1)` = elapsed - fraction of the current beat) plus the existing `is_bar_start`. The driver maps - phase→brightness with a decay envelope (bright at phase 0, decaying toward the - next beat), brightest on the bar downbeat. This is a small additive change; the - existing `is_flashing`/`_drive_metronome` path stays working. -- Keep `_drive_metronome` (FS4, not plugin-bound) separate for now; both share the - one `beat` tick computed in `poll_indicators`. Note possible future unification. - -### 6. loopjefe plugin package (`plugins/loopjefe/__init__.py`) - -- `register(*LOOPJEFE_URIS, customization=PluginCustomization( - display_name="LoopJefe", - footswitch_behavior_fn=make_loopjefe_behavior))`. - URIs: `http://treefallsound.com/plugins/loopjefe` and `.../loopjefe-2x2`. -- `LoopjefeBehavior`: `momentary = True`; - `output_subscriptions() -> ("state", "measure_number")`; caches `state:int` and - `measure_number:int` from `on_output`. `led_color(beat)` and `led_style(beat)` - — **no brightness math** (the driver owns the gradient): - - **Style by state**: Empty(0) → `(None, SOLID)` (off); Stopped(5) → - `((80,80,80), SOLID)` (steady grey); all other states → `(color, METRONOME)`. - - **Base color by state** (sensible defaults, tune later): Record Arm(1)/Record - Close(3) → blue `(0,80,255)`; Recording(2) → red `(255,0,0)`; Playback(4) → - green `(0,255,0)`; Overdub Arm(6)/Overdub Close(8) → blue `(0,80,255)`; - Overdub(7) → orange `(255,140,0)`. - - **Loop-downbeat color swap**: when `measure_number == 0` (the loop's own - first measure), return a **distinct variant** of the state color (e.g. a - whiter/brighter tint) so the loop downbeat reads differently from the rest of - the loop. This is the *only* thing `measure_number` changes — pure color - selection; the pulse envelope still comes from the driver/BeatGrid. - -### 7. Cross-repo: loopjefe LV2 — new `measure_number` output + monitored outputs - -In `loopjefe-lv2` (**both** `loopjefe/` and `loopjefe-2x2/` bundles — independent -copies): -- **New `measure_number` OUTPUT control port**: `lv2:ControlPort, lv2:OutputPort`, - `lv2:integer`, min 0, default 0 — the current measure index *within the loop* - (0 = the loop's own downbeat/first measure). Add to the port enum, `connect_port` - case, and write it each `run()` from the loop's phase math (the plugin already - computes loop bar/phase via `phaseMapAbsBeats`/`phaseMapPhase01` in - `transport.h`). Bump the port count and `lv2:index`es; update the `.ttl` in both - bundles. -- **Monitor both outputs**: add `state` **and** `measure_number` to the modgui's - `modgui:monitoredOutputs` so mod-host monitors them and emits `output_set` - (mod-ui auto-sends `monitor_output` for `extinfo['monitoredOutputs']` on plugin - add). Without this, pi-Stomp never receives either. Confirm the modgui.ttl block. -- Rebuild/install both bundles; verify with lilv/`make validate`. -- Update `README.md`'s port table (already stale — still lists the old 5-state - enum) to the current 9-state `state` + `measure_number`. - -## Tests - -- `tests/test_ws_protocol.py`: `output_set` parses to `OutputSetMessage` (happy + - edge). -- `tests/test_websocket_bridge.py` (or extend): interesting-set membership — a - subscribed `output_set` survives, a non-subscribed one is dropped; empty set - drops all (regression: today's behavior). -- New `tests/test_loopjefe_behavior.py`: state→color/style map for all 9 states — - Empty→`(None, SOLID)`, Stopped→`(grey, SOLID)`, active states→`(color, - METRONOME)`; `measure_number == 0` returns the distinct downbeat-variant color, - non-zero returns the base color. (Brightness/pulse is the driver's job — tested - separately.) -- `tests/test_beatsync.py` (extend): `TickState.beat_phase` advances 0→1 across a - beat and resets on crossing; `is_bar_start` on the downbeat. -- Driver test (extend `test_modhandler` or new): `METRONOME` render scales - brightness by `beat_phase` (bright at phase 0, dim near 1), brightest on - `is_bar_start`; `SOLID` render is steady; unanchored METRONOME → steady color. -- `tests/test_handle_footswitch_longpress.py` / `test_footswitch.py`: momentary - short-press emits `127` every press (no toggle) when - `fs.behavior.momentary`; unaffected footswitches still toggle. -- Full suite: `uv run pytest`; `pyright` zero + `ruff` clean (per CLAUDE.md). - -## Verification (end-to-end) - -1. Unit + type/lint gates green (`uv run pytest`, pyright, ruff). -2. **Emulator / device**: load a pedalboard with a loopjefe instance, MIDI-learn a - footswitch's short-press CC → `advance`, long-press CC → `reset`; set transport - rolling with a known BPM. - - Press short repeatedly → `state` advances (0→1→2→…); the footswitch LED shows - the mapped color and **pulses on the beat** (brightest on downbeat); Stopped = - steady grey; Empty = off. - - Long-press → `reset` clears the track → LED off. - - Confirm only the subscribed `output_set /graph/ state` flows (others - still dropped) — check WS debug logs for volume. -3. Confirm FS4 global metronome still flashes unchanged (single beat tick shared). - -## Open questions / future - -- **Tap-tempo footgun (document, not code):** the plugin aborts an in-progress take - if `transport_bpm` diverges from the sampled `capture_bpm` mid-record. Hitting - FS4 tap-tempo *while recording* kills that loop. Set tempo before recording. -- **Possible unification** of `_drive_metronome` into `_drive_footswitch_leds` - later (FS4 as an implicit taptempo behavior).