From f1c1b4c9924dad327f5dc111e71d6444827ec2e0 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 3 Jul 2026 20:42:32 -0400 Subject: [PATCH 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 08/18] 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). From 0b55a0199eadc4b31fea29da00694b78a3f7026b Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 18 Aug 2026 22:34:15 -0400 Subject: [PATCH 09/18] WIP --- modalapi/led_render.py | 8 ++ modalapi/modhandler.py | 14 +++ modalapi/plugin_customization.py | 4 + modalapi/websocket_bridge.py | 40 +++++--- pistomp/hardware.py | 17 ++-- pistomp/lcd320x240.py | 44 ++++++++- plugins/loopjefe/__init__.py | 29 ++++++ .../overdub-from-output-set.png | Bin 0 -> 10047 bytes .../empty.png | Bin 0 -> 9564 bytes .../recording-and-playback.png | Bin 0 -> 9635 bytes tests/test_websocket_bridge.py | 51 ++++++++++ tests/v3/test_footswitch_state_view.py | 91 ++++++++++++++++++ tests/v3/test_hardware_config.py | 36 +++++++ uilib/footswitch.py | 26 ++++- 14 files changed, 336 insertions(+), 24 deletions(-) create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png create mode 100644 tests/v3/test_footswitch_state_view.py diff --git a/modalapi/led_render.py b/modalapi/led_render.py index 231163bab..f67a181a5 100644 --- a/modalapi/led_render.py +++ b/modalapi/led_render.py @@ -52,3 +52,11 @@ def render_led_spec( ) style = LedDisplayStyle.METRONOME if (spec.pulse and state not in spec.steady_states) else LedDisplayStyle.SOLID return base, style + + +def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None: + """Short display name for the current state, or None when the plugin + declares no `labels`. Same lookup as `render_led_spec`, for the LCD.""" + if spec.labels is None: + return None + return spec.labels.get(int(output_values.get(spec.state_symbol, 0))) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 47a4e3422..4d4a57a8e 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -1020,7 +1020,10 @@ def _handle_ws_message(self, msg: WebSocketMessage): if self._current is not None: for plugin in self.current.pedalboard.plugins: if plugin.instance_id == msg.instance: + changed = plugin.output_values.get(msg.symbol) != msg.value plugin.set_output_value(msg.symbol, msg.value) + if changed: + self._repaint_state_switches(plugin, msg.symbol) break elif isinstance(msg, ParamSetMessage): @@ -1585,6 +1588,17 @@ def toggle_plugin_bypass(self, plugin): if not self._is_pedalboard_loading: self.ws_bridge.send_parameter(plugin.instance_id, BYPASS_SYMBOL, value) + def _repaint_state_switches(self, plugin, symbol: str) -> None: + """The plugin moved its LedSpec state; the switches bound to it render + that word, so they need repainting. Only the state port qualifies — + the downbeat port ticks every bar and only the LED driver reads it.""" + spec = plugin.customization.led_spec + if spec is None or symbol != spec.state_symbol: + return + for controller in plugin.controllers: + if isinstance(controller, Footswitch): + self.update_lcd_fs(footswitch=controller) + def update_lcd_fs(self, footswitch=None, bypass_change=False): self.lcd.update_footswitch(footswitch) diff --git a/modalapi/plugin_customization.py b/modalapi/plugin_customization.py index 8e6b14ba7..192b4f057 100644 --- a/modalapi/plugin_customization.py +++ b/modalapi/plugin_customization.py @@ -59,10 +59,14 @@ class LedSpec: 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. + labels: state values to short display names for the LCD. The port is an + lv2:OutputPort, so its scalePoints never reach us as a Parameter — + they have to be declared here alongside the colors. """ state_symbol: str colors: dict[int, tuple[int, int, int]] + labels: dict[int, str] | None = None pulse: bool = False off_states: frozenset[int] = frozenset() steady_states: frozenset[int] = frozenset() diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index d08d0affd..cb3a304db 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -68,6 +68,9 @@ def __init__( # via set_interesting_outputs; read here on the worker thread. The # frozenset ref-swap is atomic under the GIL (CPython only). self._interesting: frozenset[str] = frozenset() + # Latest unsubscribed output_set per "instance/symbol", replayed when a + # subscription for it arrives. Bounded by the port count, not the rate. + self._latest_outputs: dict[str, str] = {} self._wakeup: asyncio.Event = asyncio.Event() # Metrics @@ -247,18 +250,20 @@ async def _receive_messages(self, ws): continue elif message.startswith("output_set "): # 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]}") + parts = message.split(" ", 3) + if len(parts) >= 3: + inst = parts[1].removeprefix("/graph/") + key = f"{inst}/{parts[2]}" + if key in self._interesting: + self.received_queue.put(message) + self.messages_received += 1 + logging.debug(f"Received subscribed output_set: {message[:100]}") + else: + # mod-ui dumps every monitored port on connect, before + # the board binds and the subscriptions are known. Hold + # the latest value per port so the first paint isn't + # stale until the plugin next moves. + self._latest_outputs[key] = message continue self.received_queue.put(message) self.messages_received += 1 @@ -271,8 +276,16 @@ async def _receive_messages(self, ws): 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).""" + pedalboard load/rebind. Thread-safe under the GIL (frozenset ref swap). + + Set before the replay, so a value arriving mid-swap takes the queue path + rather than landing in a dict nobody drains again.""" self._interesting = keys + for key in keys: + message = self._latest_outputs.pop(key, None) + if message is not None: + self.received_queue.put(message) + self.messages_received += 1 def _get_write_buffer_size(self, ws) -> int: """Return bytes waiting in the TCP write buffer, or 0 if unavailable.""" @@ -366,6 +379,7 @@ def get_stats(self) -> dict: def clear_queue(self) -> int: """Clear all pending messages from the queue, returning num cleared.""" + self._worker._latest_outputs.clear() cleared_count = 0 try: while True: diff --git a/pistomp/hardware.py b/pistomp/hardware.py index b5af25d73..507673b55 100755 --- a/pistomp/hardware.py +++ b/pistomp/hardware.py @@ -350,8 +350,8 @@ def create_encoders(self, cfg): self.controllers[key] = control logging.debug("Created Encoder: %d, Midi Chan: %d, CC: %d" % (id, midi_channel, midi_cc)) - def get_real_midi_channel(self, cfg): - chan = 0 + def get_real_midi_channel(self, cfg, default: int = 0): + chan = default try: val = cfg[Token.HARDWARE][Token.MIDI][Token.CHANNEL] # LAME bug in Mod detects MIDI channel as one higher than sent (7 sent, seen by mod as 8) so compensate here @@ -425,10 +425,15 @@ def __apply_midi_routing(self, cfg): self.__route_section(cfg, Token.FOOTSWITCHES, self.footswitches, set_cc=False) def __init_midi_default(self): - self.__init_midi(self.cfg) - - def __init_midi(self, cfg): - self.midi_channel = self.get_real_midi_channel(cfg) + self.__init_midi(self.cfg, default=0) + + def __init_midi(self, cfg, default: int | None = None): + # A pedalboard overlay declaring no channel keeps the one already in + # force. Falling back to 0 there would re-key every controller under + # "0:" while the parameter bindings still say ":", and the + # switch would silently stop dispatching. + fallback = self.midi_channel if default is None else default + self.midi_channel = self.get_real_midi_channel(cfg, default=fallback) # TODO could iterate thru all objects here instead of handling in __init_footswitches for ac in self.analog_controls: if isinstance(ac, AnalogMidiControl.AnalogMidiControl): diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index 0e69d2976..f8ef148f9 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -27,6 +27,7 @@ import common.util as util from common.contexts import BindingDecl, ControlClass, EventKind, MidiCcEffect, ParamEffect, ShadowState from common.parameter import BYPASS_SYMBOL, Parameter, PortInfo, Symbol, Type +from modalapi.led_render import render_led_spec, state_label from modalapi.plugin import Plugin from ui.ethernet_menu import EthernetMenu from ui.footswitch_menu import FootswitchMenu @@ -860,6 +861,26 @@ def footswitch_label(self, footswitch, slot_width=None): name = param.instance_id return self.shorten_name(name, width) + def _footswitch_state(self, footswitch): + """(name, state_label, color) for a switch bound to a plugin that + publishes a state via its LedSpec, else (None, None, None). The name is + the plugin's, not the bound port's — the port is a trigger ("Advance"), + which says nothing about which loop this is.""" + param = footswitch.parameter + if param is None or self.current is None: + return None, None, None + plugin = self.current.pedalboard.find_plugin(param.instance_id) + if plugin is None: + return None, None, None + spec = plugin.customization.led_spec + if spec is None: + return None, None, None + label = state_label(spec, plugin.output_values) + if label is None: + return None, None, None + color, _style = render_led_spec(spec, plugin.output_values) + return plugin.display_name, label, color + def draw_footswitches(self): # One slot-ordered pass over the physical switches, so selection order is # the stable physical order regardless of plugin/pedalboard ordering. @@ -872,6 +893,7 @@ def draw_footswitches(self): slot_w = pitch for fs in sorted(self.footswitches, key=lambda f: f.id): x = pitch * fs.id + state = None if fs.preset_callback_arg is not None: label = self.footswitch_label(fs, slot_w) fs.set_display_label(label) @@ -883,9 +905,14 @@ def draw_footswitches(self): fs.toggled = active fs.set_led(active) # a press never touches toggled for preset switches elif fs.parameter is not None: - label = self.footswitch_label(fs, slot_w) + name, state, state_color = self._footswitch_state(fs) + if state is not None: + label = name + color = state_color + else: + label = self.footswitch_label(fs, slot_w) + color = accent_color_for(fs.category) fs.set_display_label(label) - color = accent_color_for(fs.category) action = self.footswitch_event else: label = fs.get_display_label() or "" @@ -898,6 +925,7 @@ def draw_footswitches(self): not fs.toggled, small_font=self.tiny_font, taptempo=fs.taptempo, + state_label=state, parent=self.footswitch_panel, action=action, object=fs, @@ -915,14 +943,22 @@ def update_footswitch(self, footswitch): footswitch.toggled = active footswitch.set_led(active) wfs.color = FootswitchWidget.DEFAULT_COLOR + wfs.state_label = None elif footswitch.parameter is not None: # Binding may be new (e.g. MIDI learn) — reflect label + color. - footswitch.set_display_label(self.footswitch_label(footswitch, slot_w)) - wfs.color = accent_color_for(footswitch.category) + name, state, state_color = self._footswitch_state(footswitch) + if state is not None: + footswitch.set_display_label(name) + wfs.color = state_color + else: + footswitch.set_display_label(self.footswitch_label(footswitch, slot_w)) + wfs.color = accent_color_for(footswitch.category) + wfs.state_label = state wfs.action = self.footswitch_event else: wfs.color = None wfs.action = None + wfs.state_label = None wfs.toggle(not footswitch.toggled) wfs.label = footswitch.get_display_label() or "" wfs.refresh() diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py index bda9e7809..699760a4d 100644 --- a/plugins/loopjefe/__init__.py +++ b/plugins/loopjefe/__init__.py @@ -8,9 +8,15 @@ from __future__ import annotations +import re +from typing import TYPE_CHECKING + from modalapi.plugin_customization import LedSpec, PluginCustomization from plugins.customization import register +if TYPE_CHECKING: + from modalapi.plugin import Plugin + LOOPJEFE_URIS = ( "http://treefallsound.com/plugins/loopjefe", "http://treefallsound.com/plugins/loopjefe-2x2", @@ -31,9 +37,24 @@ 8: (0, 80, 255), # Overdub Close } +# Short enough for a 320px/4 footswitch slot; the TTL scalePoints spell them +# out in full ("Record Arm", "Overdub Close"). +_STATE_LABELS: dict[int, str] = { + _STATE_EMPTY: "Empty", + 1: "Arm", + 2: "Rec", + 3: "Close", + 4: "Play", + _STATE_STOPPED: "Stop", + 6: "Arm", + 7: "Dub", + 8: "Close", +} + _LOOPJEFE_LED_SPEC = LedSpec( state_symbol="state", colors=_STATE_COLORS, + labels=_STATE_LABELS, pulse=True, off_states=frozenset({_STATE_EMPTY}), steady_states=frozenset({_STATE_STOPPED}), @@ -41,10 +62,18 @@ downbeat_tint=60, ) +def _track_name(plugin: "Plugin") -> str | None: + """"Loop 2", not "LoopJefe" — every track is the same plugin, so the + instance number is the only thing that tells two switches apart.""" + match = re.search(r"(\d+)$", plugin.instance_id) + return f"Loop {match.group(1)}" if match else None + + register( *LOOPJEFE_URIS, customization=PluginCustomization( display_name="LoopJefe", + display_name_fn=_track_name, led_spec=_LOOPJEFE_LED_SPEC, ), ) diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png new file mode 100644 index 0000000000000000000000000000000000000000..d8b623686c3897a564554cf357121b90b2225e50 GIT binary patch literal 10047 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%0Ad>%l(U&jN0LgT96F^KWF#mvI zCIse0=Sk}AIdGIDU?ls#@9$D)@t?PE--1VA0OK|=ib}qH`#^%Mut|gw9nd8BZL-^X zLv~KHN^PYeYLBY3$ji;0w-W`|wmA+80!T8< z_0j$Kel<1oTk?{ZZkJ0{em>oV_ZjpbSRA@>)Kj9r)Yz|fj`UqFGR-+!hYHuk1QI{O z#Kh#*tyDuI&5e!s;I#z*_wVzyN;1EEVU(0i>|HrMJ_h?kUQQ0mj#Q-I;9(=oeZkFO z(?l&TFV`N7TY-HDGumsytP&EsERis?aybgaxSw_|q;6#!U2x}>ofX|B-BwM=XK`1{ zx&xCmTStq`vp_Lml?sB+AOowOozI z#xZ<@|254-wM;-}wZkiU?JHH|%c~>td`gR!1(jbKW00f9Wz$03gYN_V-j5b}JH!=1 z3oR-FXaE;;e{b(>t73*A$AZ4E3e#Hd!3ELk_CO!QMnnlkhx>be`it+`)fKemTRcn* zRY``CZ7D$k3dbB}XV`mTY{v5PKNRo!0c$qqTTz-owZI;)Hr`LO1F!nQPZ06g!8F?2cIK`ry|-eFoh>owo-ero%H@Y+a$&S3*CL-0x3)x9dQ+iIr9 n#)9B2f(a+Nxki60@#ONMgGq67-ImxtQu_Fjifo~b;k*9=^f-W; literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png new file mode 100644 index 0000000000000000000000000000000000000000..ff301258406c0ebf02b154a3eab402c87f5605e1 GIT binary patch literal 9564 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 l#hVDF(Kn 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 e_M Plugin: + advance = make_parameter("advance", instance_id, value=0.0) + advance.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl + uri = LOOPJEFE_URIS[0] + return Plugin( + instance_id, + {Symbol("advance"): advance}, + {}, + "Looper", + uri=uri, + customization=lookup(uri), + ) + + +def _bind(v3_system: SystemFixture, plugins: list[Plugin]) -> None: + """Learn each plugin's `advance` onto the footswitch of the same index.""" + handler = v3_system.handler + assert handler.current + handler.current.pedalboard.plugins = plugins + for i, plugin in enumerate(plugins): + fs = v3_system.hw.footswitches[i] + binding = next(k for k, c in v3_system.hw.controllers.items() if c is fs) + channel, cc = binding.split(":") + v3_system.ws_bridge.inject(f"midi_map /graph/{plugin.instance_id} advance {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + + +def test_two_track_looper_footswitch_bar(v3_system: SystemFixture, make_parameter, snapshot): + """Two looper tracks, each mid-flight in a different state.""" + handler = v3_system.handler + lcd = handler.lcd + plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")] + _bind(v3_system, plugins) + + lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches) + lcd.draw_main_panel() + snapshot("empty") + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 2.0") # Recording + v3_system.ws_bridge.inject("output_set /graph/loopjefe_2 state 4.0") # Playback + handler.poll_ws_messages() + lcd.update_footswitches() + snapshot("recording-and-playback") + + +def test_state_view_falls_back_when_plugin_has_no_led_spec(v3_system: SystemFixture, make_parameter): + """A plugin without a LedSpec keeps the ordinary dot-and-label slot -- the + state view must not leak onto every bound footswitch.""" + handler = v3_system.handler + assert handler.current + + param = make_parameter("gain", "/Reverb", value=0.0) + plugin = Plugin("/Reverb", {Symbol("gain"): param}, {}, "Reverb") + handler.current.pedalboard.plugins = [plugin] + + name, state, color = handler.lcd._footswitch_state(v3_system.hw.footswitches[0]) + assert (name, state, color) == (None, None, None) + + +def test_state_view_repaints_on_output_set(v3_system: SystemFixture, make_parameter, snapshot): + """The plugin's state arrives asynchronously over the socket, long after the + press that caused it. Without a repaint on `output_set` the slot keeps + whatever it painted at press time and the looper reads "Empty" forever.""" + handler = v3_system.handler + lcd = handler.lcd + plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")] + _bind(v3_system, plugins) + + lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches) + lcd.draw_main_panel() + + # No press, no reload -- only the socket tells us the looper moved. + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 7.0") # Overdub + handler.poll_ws_messages() + snapshot("overdub-from-output-set") diff --git a/tests/v3/test_hardware_config.py b/tests/v3/test_hardware_config.py index 8bbc951f1..3bf51d570 100644 --- a/tests/v3/test_hardware_config.py +++ b/tests/v3/test_hardware_config.py @@ -195,3 +195,39 @@ def test_longpress_enum_covers_every_handler_callback(v3_system: SystemFixture): # set_mod_tap_tempo shares the callback map but is reachable only via the # `tap_tempo:` key, which passes a BPM no longpress can supply. assert set(v3_system.handler.callbacks) - {"set_mod_tap_tempo"} == enum + + +# --------------------------------------------------------------------------- +# MIDI channel — the overlay must not clobber it +# --------------------------------------------------------------------------- + +def test_overlay_without_midi_block_keeps_channel(v3_system: SystemFixture): + """A pedalboard config that says nothing about MIDI leaves the channel alone. + + Resetting it re-keys every controller under "0:" while the parameter + bindings still read ":", so the switch stops dispatching and its + longpress goes out on the wrong channel. + """ + hw = v3_system.hw + channel = hw.midi_channel + fs = hw.footswitches[0] + key = fs.dispatch_key + + hw.reinit(_cfg(footswitches=[{"id": 0, "midi_CC": fs.midi_CC}])) + + assert hw.midi_channel == channel + assert hw.footswitches[0].midi_channel == channel + assert hw.footswitches[0].dispatch_key == key + assert hw.controllers[key] is hw.footswitches[0] + + +def test_overlay_may_still_override_midi_channel(v3_system: SystemFixture): + """An overlay that does declare a channel still wins (1-based in config).""" + hw = v3_system.hw + + cfg = _cfg(footswitches=[{"id": 0, "midi_CC": 60}]) + cfg[Token.HARDWARE][Token.MIDI] = {Token.CHANNEL: 5} + hw.reinit(cfg) + + assert hw.midi_channel == 4 + assert hw.footswitches[0].dispatch_key == "4:60" diff --git a/uilib/footswitch.py b/uilib/footswitch.py index 69eb65248..ea75b8458 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -88,6 +88,10 @@ class FootswitchWidget(Widget): _TAP_Y_LABEL = 2 # "TAP" header (14pt Bold) _TAP_Y_BPM = 17 # BPM digits (16pt Bold) + # Two-line state view, same rhythm as the tap view. + _STATE_Y_NAME = 2 + _STATE_Y_STATE = 17 + font: pygame._freetype.Font small_font: pygame._freetype.Font | None label: str | None @@ -95,6 +99,7 @@ class FootswitchWidget(Widget): num: int | None is_bypassed: bool taptempo: TapTempoProtocol | None + state_label: str | None _pulse_on: bool def __init__( @@ -105,6 +110,7 @@ def __init__( is_bypassed: bool, small_font: pygame._freetype.Font | None = None, taptempo: TapTempoProtocol | None = None, + state_label: str | None = None, **kwargs, ): self._init_attrs(Widget.INH_ATTRS, kwargs) @@ -116,6 +122,7 @@ def __init__( self.num = None self.is_bypassed = is_bypassed self.taptempo = taptempo + self.state_label = state_label self._pulse_on = True def _tap_active(self) -> bool: @@ -154,7 +161,9 @@ def _draw(self, ctx: PaintContext) -> None: is_on = not self.is_bypassed has_label = bool(self.label) - if has_label: + if self.state_label is not None: + self._draw_state(ctx, w) + elif has_label: self._draw_dot_and_label(ctx, w, is_on) else: self._draw_letter_badge(ctx, w, is_on) @@ -180,6 +189,21 @@ def _draw_tap(self, ctx: PaintContext) -> None: dw, _ = get_text_size(digits, bpm_font) ctx.draw_text(((w - dw) // 2, self._TAP_Y_BPM), digits, fill=self.TAP_BPM_COLOR, font=bpm_font) + def _draw_state(self, ctx: PaintContext, w: int) -> None: + """Two-line view for a switch whose plugin publishes a state: name on + top, state below in the state's own color. No dot — the coloured state + word is the indicator, and 36px holds two lines or a dot, not both.""" + font = self._slot_font() + + name = self._fit(self.label or "", w - 2, font) + nw, _ = get_text_size(name, font) + ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=font) + + state = self._fit(self.state_label or "", w - 2, font) + sw, _ = get_text_size(state, font) + fill = self.color if self.color is not None else self.BOUND_OFF_LABEL + ctx.draw_text(((w - sw) // 2, self._STATE_Y_STATE), state, fill=fill, font=font) + def _draw_dot_and_label(self, ctx: PaintContext, w: int, is_on: bool) -> None: """Small dot on top, label centered below.""" cx = w // 2 From 7b3d9534d6b5e97aa62b2c15dc568a2b8628969c Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 18 Aug 2026 22:47:42 -0400 Subject: [PATCH 10/18] progress border for playback/rec/overdub --- common/loop_progress.py | 42 +++++ modalapi/led_render.py | 28 ++++ modalapi/modhandler.py | 18 ++- modalapi/plugin.py | 2 + modalapi/plugin_customization.py | 7 + pistomp/beatsync.py | 3 + pistomp/lcd320x240.py | 12 ++ plugins/loopjefe/__init__.py | 7 + .../recording-chase.png | Bin 0 -> 10062 bytes .../play-bar3-beat3.png | Bin 0 -> 10438 bytes .../recording-and-playback.png | Bin 9635 -> 9658 bytes tests/v3/test_footswitch_state_view.py | 82 ++++++++++ uilib/footswitch.py | 81 ++++++++++ uilib/glyphs/perimeter_progress.py | 153 ++++++++++++++++++ 14 files changed, 434 insertions(+), 1 deletion(-) create mode 100644 common/loop_progress.py create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png create mode 100644 uilib/glyphs/perimeter_progress.py diff --git a/common/loop_progress.py b/common/loop_progress.py new file mode 100644 index 000000000..1d269ab44 --- /dev/null +++ b/common/loop_progress.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with pi-stomp. If not, see . + +"""How far a looping plugin is through its loop, as the footswitch strip draws it. + +Shared between the renderer that derives it (`modalapi.led_render`) and the +widget that paints it (`uilib.footswitch`), which sit on opposite sides of the +module DAG. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum, auto + + +class LoopFill(Enum): + FILL = auto() # determinate: `position` of the perimeter is behind us + STATIC = auto() # the loop exists but isn't moving + CHASE = auto() # length unknown: a head sweeping at `position` + + +@dataclass(frozen=True) +class LoopProgress: + mode: LoopFill + color: tuple[int, int, int] + segments: int # bars in the loop; 0 when the length isn't known yet + position: float = 0.0 # turns, [0, 1) diff --git a/modalapi/led_render.py b/modalapi/led_render.py index f67a181a5..f3e3ce452 100644 --- a/modalapi/led_render.py +++ b/modalapi/led_render.py @@ -26,6 +26,8 @@ from enum import Enum, auto from typing import TYPE_CHECKING +from common.loop_progress import LoopFill, LoopProgress + if TYPE_CHECKING: from modalapi.plugin_customization import LedSpec @@ -60,3 +62,29 @@ def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None: if spec.labels is None: return None return spec.labels.get(int(output_values.get(spec.state_symbol, 0))) + + +def loop_progress(spec: LedSpec, output_values: dict[str, float], bar_phase: float) -> LoopProgress | None: + """Where the plugin is through its loop, or None if it has no loop to be + through. `bar_phase` interpolates within the current bar — the plugin only + publishes a bar index, and a per-sample position port would be a monitored + output changing every process cycle.""" + if spec.bars_symbol is None or spec.downbeat_symbol is None: + return None + color, _style = render_led_spec(spec, output_values) + if color is None: + return None + + state = int(output_values.get(spec.state_symbol, 0)) + bars = int(output_values.get(spec.bars_symbol, 0)) + measure = int(output_values.get(spec.downbeat_symbol, 0)) + + # Past the length it declared (an overdub that outgrew the head loop) is + # the same situation as a take still recording: a position, no denominator. + if state in spec.chase_states or (bars > 0 and measure >= bars): + return LoopProgress(LoopFill.CHASE, color, 0, bar_phase) + if bars <= 0: + return None + if state in spec.steady_states: + return LoopProgress(LoopFill.STATIC, color, bars) + return LoopProgress(LoopFill.FILL, color, bars, (measure + bar_phase) / bars) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 4d4a57a8e..aab7b6827 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -76,7 +76,8 @@ from plugins.customization import lookup as plugin_lookup from plugins.customization import patch_extra_data import modalapi.external_midi as ExternalMidi -from modalapi.led_render import LedDisplayStyle, render_led_spec +from common.loop_progress import LoopProgress +from modalapi.led_render import LedDisplayStyle, loop_progress, render_led_spec from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute from pistomp.lcd320x240 import Lcd @@ -272,6 +273,7 @@ def __init__(self, audiocard: Audiocard, homedir, data_dir="/home/pistomp/data") self.chord_helper = FootswitchChords() self.beat_grid = BeatGrid() + self._last_beat: TickState | None = None self._taptempo_fs_cache: Footswitch | None = None # First raw-CC longpress sends 127; alternates thereafter. self._longpress_cc_state: dict[LongpressCcKey, bool] = {} @@ -638,6 +640,9 @@ def _drive_footswitch_leds(self, beat: TickState | None = None) -> None: return if beat is None: beat = self.beat_grid.tick(_now_us()) + # The LCD reads the phase from here rather than ticking the grid + # itself; a second tick() would swallow the beat-crossing edge. + self._last_beat = beat taptempo_fs = self._taptempo_footswitch() for fs in self.hardware.footswitches: if fs is taptempo_fs: @@ -685,6 +690,17 @@ def _render_footswitch( color = accent_color_for(fs.category) if fs.category is not None else (255, 255, 255) return color, LedDisplayStyle.SOLID + def footswitch_loop_progress(self, fs: Footswitch) -> LoopProgress | None: + """Loop position for a switch bound to a plugin that publishes one.""" + plugin = self._bound_plugin(fs) + if plugin is None: + return None + spec = plugin.customization.led_spec + if spec is None: + return None + beat = self._last_beat + return loop_progress(spec, plugin.output_values, beat.bar_phase if beat is not None else 0.0) + def _bound_plugin(self, fs: Footswitch): if fs.parameter is None or self._current is None: return None diff --git a/modalapi/plugin.py b/modalapi/plugin.py index e1f8a9599..190198b56 100755 --- a/modalapi/plugin.py +++ b/modalapi/plugin.py @@ -88,6 +88,8 @@ def monitored_output_symbols(self) -> tuple[str, ...]: symbols = [spec.state_symbol] if spec.downbeat_symbol is not None: symbols.append(spec.downbeat_symbol) + if spec.bars_symbol is not None: + symbols.append(spec.bars_symbol) return tuple(symbols) def set_output_value(self, symbol: str, value: float) -> None: diff --git a/modalapi/plugin_customization.py b/modalapi/plugin_customization.py index 192b4f057..03967d9d4 100644 --- a/modalapi/plugin_customization.py +++ b/modalapi/plugin_customization.py @@ -59,6 +59,11 @@ class LedSpec: downbeat" — brightens the color by `downbeat_tint` per channel. off_states / steady_states: state values that render as off, or as a steady (non-pulsing) color even when `pulse` is True. + bars_symbol: an output port carrying the loop's length in bars — the + denominator `downbeat_symbol` counts against, so the pair yields a + position around the footswitch's progress border. 0 means unknown. + chase_states: state values that have no length to be a fraction of (a + take still being recorded) but should still show motion. labels: state values to short display names for the LCD. The port is an lv2:OutputPort, so its scalePoints never reach us as a Parameter — they have to be declared here alongside the colors. @@ -72,6 +77,8 @@ class LedSpec: steady_states: frozenset[int] = frozenset() downbeat_symbol: str | None = None downbeat_tint: int = 60 + bars_symbol: str | None = None + chase_states: frozenset[int] = frozenset() @dataclass(frozen=True) diff --git a/pistomp/beatsync.py b/pistomp/beatsync.py index 413b09a32..0add31a8a 100644 --- a/pistomp/beatsync.py +++ b/pistomp/beatsync.py @@ -36,6 +36,7 @@ class TickState: bpm: float bpb: float beat_phase: float = 0.0 # normalized [0, 1) within the current beat + bar_phase: float = 0.0 # normalized [0, 1) within the current bar class BeatGrid: @@ -108,6 +109,7 @@ def tick(self, now_us: int) -> TickState: pos = self._anchor_pos + delta_us * self._bpm / 60_000_000.0 current_beat_idx = int(pos // 1) beat_phase = pos - current_beat_idx # fractional part [0, 1) + bar_phase = (pos % self._bpb) / self._bpb if current_beat_idx > self._last_beat_idx: self._last_beat_idx = current_beat_idx @@ -124,4 +126,5 @@ def tick(self, now_us: int) -> TickState: bpm=self._bpm, bpb=self._bpb, beat_phase=beat_phase, + bar_phase=bar_phase, ) diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index f8ef148f9..a581f5c62 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -881,6 +881,12 @@ def _footswitch_state(self, footswitch): color, _style = render_led_spec(spec, plugin.output_values) return plugin.display_name, label, color + def _progress_fn(self, footswitch, state_label: str | None): + """Only the state view has a border to draw the loop position on.""" + if state_label is None or self.handler is None: + return None + return functools.partial(self.handler.footswitch_loop_progress, footswitch) + def draw_footswitches(self): # One slot-ordered pass over the physical switches, so selection order is # the stable physical order regardless of plugin/pedalboard ordering. @@ -926,10 +932,12 @@ def draw_footswitches(self): small_font=self.tiny_font, taptempo=fs.taptempo, state_label=state, + progress_fn=self._progress_fn(fs, state), parent=self.footswitch_panel, action=action, object=fs, ) + p.poll_progress() self.w_footswitches.append(p) self.footswitch_panel.refresh() @@ -944,6 +952,7 @@ def update_footswitch(self, footswitch): footswitch.set_led(active) wfs.color = FootswitchWidget.DEFAULT_COLOR wfs.state_label = None + wfs.progress_fn = None elif footswitch.parameter is not None: # Binding may be new (e.g. MIDI learn) — reflect label + color. name, state, state_color = self._footswitch_state(footswitch) @@ -954,13 +963,16 @@ def update_footswitch(self, footswitch): footswitch.set_display_label(self.footswitch_label(footswitch, slot_w)) wfs.color = accent_color_for(footswitch.category) wfs.state_label = state + wfs.progress_fn = self._progress_fn(footswitch, state) wfs.action = self.footswitch_event else: wfs.color = None wfs.action = None wfs.state_label = None + wfs.progress_fn = None wfs.toggle(not footswitch.toggled) wfs.label = footswitch.get_display_label() or "" + wfs.poll_progress() wfs.refresh() break diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py index 699760a4d..4e2d7e609 100644 --- a/plugins/loopjefe/__init__.py +++ b/plugins/loopjefe/__init__.py @@ -24,6 +24,8 @@ # LoopJefePlugin state values (../loopjefe-lv2/src/types.h) _STATE_EMPTY = 0 +_STATE_RECORDING = 2 +_STATE_RECORD_CLOSE = 3 _STATE_STOPPED = 5 _STATE_COLORS: dict[int, tuple[int, int, int]] = { @@ -60,6 +62,11 @@ steady_states=frozenset({_STATE_STOPPED}), downbeat_symbol="measure_number", downbeat_tint=60, + bars_symbol="loop_bars", + # The initial take has no length yet to be a fraction of, so the progress + # border sweeps instead of filling. Record Arm is excluded: nothing is + # being captured, so nothing should move. + chase_states=frozenset({_STATE_RECORDING, _STATE_RECORD_CLOSE}), ) def _track_name(plugin: "Plugin") -> str | None: diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png new file mode 100644 index 0000000000000000000000000000000000000000..a78383119dc899a1af2cb53621fcf81863fb33dd GIT binary patch literal 10062 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 literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png new file mode 100644 index 0000000000000000000000000000000000000000..3f22389e4a7abfe259baa28e1968467d457faa46 GIT binary patch literal 10438 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 JE@}Age*u#rMF9W+ literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png index c423500bd915db9d4184e965d5609e01e752997d..515a8e7e696c18c32d9e2fb85fa0f901c55cbdb9 100644 GIT binary patch literal 9658 zcmd6t^;eW{)b5865owSV5ky)*QW{641qBqOySqC?K%_*vL_h>YB&E}#1xX1}y1Qe5 z;oW}Md)7MZ`~_!zfLYAUGxu}fJ3iO734fv@M?^qJfIuLK6yzVOArM$H@O&E|8-8L` zIVBK?n+S!6_cc7zH~xC*QdnT_ZL`Qrw?A+$iDEM`y}Bi`=Oa}9Q-uPb z3D?$fMw!#x#UEwNuLzaTC-XgQ! zc9}9*8<*11chfFyoA9T|>P{3EH`W@qV880uH1IoUqc&JpA;cn74n<~F*VLSxoQ#i; zTlJKcmKyP={QUV7pQ%9O_waD*+>W=857BA1>jBeIVcTTq`r{P-OELPd`e#sKmz7E2 z0>N#@A)%q<9Xy06F>&!+wE|?;afANv-@hj&%C)!&LcW{w2fut-Ute!+ZCzVi+hZD5 zQ&V#Z7dJjJahA4yU}b-KdAYV$Oe)i1%-XZf*w$2WI>!?(Xgb0|Vc_eUrG+H-FmG+k1zF3d)b~Fzdxg<7VaDa-!y+Le;Y@(_olds|FD@=(c$^XxZ^v}lSXu2H z9uni@e^4uU|NcEIiJ@4=jqBHYmiD$L8ww?lHviUXa0=bKSDlmdfG3H>ec@b>m0P#k z5p#Az+T6^op`n3`hbJr|;^FQ-t6r_k6NYkfcW0$5Z8a{HAwre3x677ltuHLNU10oN zjt}nR{~8%NO!`n+nTVS4^!6SZ9feC)IZQ>z#B^LVxvr3Vjr6983=Iknp~z^G1L2(f zPK7Z|adAUqr6wC28{gjZW&T#4S^*_K zuC1_736_K(dOtZi8MF9a%Fla4yEhyb9Dnb+ik245qjTF>wSx8K<+t)I_)OyB;uaPb z^&DKBoS#2`R%EB7JD(ULvd%E)Pbn%Yn&*Jcr?aZ9tEfVB4ea6V@ogpt#01g>&1(dsHi9n&R@yDCMG6^hgm2Io^d9~ zniN}Yoh!1CU%q_#uay>eqI)=6z-3W6YWZ)eNk>}*^}!3O_aS(!pPsP4uKhXV$XZZ3 zZtF|nWHIK8AA;mbvay-iYUvvpfo)&*`k4u9ahj85hA{W>*!)vqv5!RH?2*HI&=z>n%?QG1I3Jg#=)%Ia z`)~SNk*-ka;U99Qz z57wO{0%NfyouSD5zmLV7j~{V;=BF+&RR!fHc8apSi|42xgki)C%x|KM#^O|^tZE~% zhKBMXU}wW(vVrptE-=jiX>q(#kcAysHlX$pWKy0 zQ8xcb;$5KofdLzkaMtxcF)F55ndz zqN1XHM}Iv8%zJ3~Zb!5sb4cn*Z0Sl%OXKCtds9QW?`vz*NvR-}l_T4gmLOs0gJ;^K z=%aR{1nD-?92%MA98^{N4mT!DjE!L%uQM>*VrDKYFE9N3x!>}VoFU&m{**6YzC_YA zS!kJ=nZ*YD#l^m_sHkXV#c?-R&2FmE4{j{3%hWP{$dBlYpWa=c!*v0RpWB`iHM&3N zc2qK@80hJh^yh~Q)N6}%?Elt2QrTKwuJ%4yby@l;Bq+GQzwhedF;(NT)ERp#JUm>i z?EGx6&!FD(DLbVi%R8T=O@@1pH`&=O*T+iddAqy22lWy^eE9Gnj2L2oBSCStS6NQ3 zqoSfBL)=5Z!TVcQ)_vw!cFH%0hlgclWhNcbk_W@;bfN=I^*H(;YpSd3ciQQf`Z67C zZR6!xBqb%GNKV22btiD0u0QP!Y%pmRj-9!OEkHvEB|?8Ag10e$sq8k6-DJy1Ma#&m znF)^eE7RH8*}RR%$hw!X!uWVf6!vilwSIHm%+?U()^_97&U}qkDE;3klyV0>CcWe? z5by6f8N<0B@<=<)zS=yarlfq!+YouJd2g|oBK+Zb(Z`w^Q6ZscCS*8=KS|e6j+Y&+ ztvS6ig2hyM25P*B@~wi4uAr*aBVC9j+*XGpBMCQFlv($-XHe&EDn+Pzuf0YL+J-o6 z$l&Fp2&xPTFY+y{^b)I~Pq|7-)@?(jJ!iw&h_BxZ zj9g97)ED*IWsxc~&yc4^2~kj9s%*z+mzFw=OCfl_!Alb3 zlMOz5ySw_HGa>o;>>eH-bW!aK&T(;ZSFc_TH-k!7T)g$6yKs(lW@ct!d|ZJ?Lsk|q zqRqH`yil|FIXh+RmoO5>cO41Bo?C>KQhYR#%gdH1bO3F9j$F*UCclO51T?SCVBez9 zmru016rm=0GFF%^9dU4|B5-kgb;nVggM^|U^9QFsd-d2Y=GIN z-cnRE{R#jhETs*CQm@U(HaO#Mz-lOMGYCA>1 zqSIh>X{J#6+)f{5o!TN~bkF^blSwe)zT3)r8h|U0q$R7s#w_#*5?2 zUYoFh*B2czjM9+mXBQmmpWenXNP5Sg?eu3o@b<3*qBx$5IZZnqGl^N4pVw{jZ}|2N z_GdP&?$f8x^!AAf%fA7wd#S%RUqs)~wqK@BFH%(0!zRbFvKpRHsIuAnN^3G1UY zbaV>U3SP$?2WsH@PMe>f93PWjxZJzGoBAyzBt+748^Oqunmh)@sIjyeXhb%EBIpxFSp1nP__Ngl=xfitww#q&=$2L-|pT$^On<2Z>*X6y7hvoZ(kS zzd7)DMkXg!F!??Y2rZuw;rS7xKl#b#U5K5oefNNX@z(bss%$}N6|Qbcj8yxCy-iG% z%~d-;Klj+0l*BIT*DTh*9U#~p+&~#@o%+=JJKqLn`SD$QTiapjy93nzE-nIhV^K5r z#KhjkUBbg#t@(}-zI~Fd#FNy7L9glS>qGmYBxswOVkElw)s#6>Gs2bINAAb6uM$(>mW1{+%Y7n}(%Y@fZei zcS6;IyVJAZL#}o)Ch;0bwc5t%PzIbVB%Plg?RiQ_{64L7Dz@-)JI0)DbukM4Mkd|S zk;Ql}bYC-NrWdxQ*(`pmn9Jz9jpe0iZ*QMDXQ0j=Z}uZ`WvJUs>#;yJtCj*HEJ=_^PX%0@wQ zhm&!OOucHy`#!abx!O(s=cGRhQ4pO&Lql%YXSKDp7kg3!^6#^U(Z_V$%JhZ4^-If! zD*T~>fd(N|=A$S5tFkuP2w-+*`OMu4~txc&BeMS_BY zxHP|>9Zoo`t*&}|ds7lTQc_ZS^r#I8tjca8F){IzQ;u$~+8w>xeNY?qt}E~4S>AP- z0nwj&b@nMU$Q-_3p73hF`u5Cn3~I_V)2c)s(RP zpX9f1r`Oh$as~h!*i_%Ar==-HPwwtIk4+VJapLayk z-innMM3G>ItPfT>QwZRF(6N4syh8NU~jF}2?tZ=O7`$MX&g~YbP;>^s(gC7-wHyT=)Bfm4V zv9UEBP5Ruvefu)W4Ir*ow$QLJ3v=^E_#<8Mu}-!>&KL@Hi-#w=%T!oU@K3#0T=ETC z+Lg66pOalPiOOpPZGb?XlAZ9aAT^>nOhLI=>$Al>pJs{Rrer>>=yTv*7Jpt!NT zoS&b+T#Wk61X?ycI@;dG=5!{Ev2*C}-@l+rKt^Wg-}f9RyDO-fNV2N9{DaAk*1 z*nYM(9M}?82}-!Ftxc`K6*2`*ir9~pv`kF}w7t338lRq?{`D)*ncLK}!sn7ZtrS4N7i$I=|CH?D;4V;k zQbNLQ78X-;bM)~sNJqf)jJ_p`h|8BQ-O;J!W@Qak$@|BSAW`SDve14Rj(@71u3WjI z%vxSnwhO63M=eiDaGR5J6=dl@JD{j2Dmt1KUE|^6lE!cLMxKR}oxR$5;Z0Oj@BYfr z#`^mHi;?khFZBKjXTs{~R+Dr9#upM6ECs%UAUaagWpWXv2(DRL_CtpFV9d z{-b)AggYF4#8LM*03=0dxdI8#|@Oe@9j~}OdnHReH`i*uImB+`&Ky?iV zBRWcn+)geoI_2g>v7W%jnSO^U8l1g7J?Y{en|;z3XK`#%e4n<)v*zQ4LcMn>jb zbA~=xgRdI-i5uKvX=w>U>Nv*6!$V}?n$sZ&H^j#DIwXACUJTv4$+hU%$o= zf4I9=+%&soF)}<1>M+NZ^>fsZ1qh0F2IQ>SRi8e60?aEfkNb0en{u4|$XQug+0ReX zX;C?P5-bgXb~M_%KJHG9uK&U6F97CjAIv=_GrTtW6 zLqUN8Hx;9_e|=e*TFzFLG;RreHj-8V(iuYftA2yx=3y)$iqDz%env(HNCeNRN?YNy zjybaOdFPrZ?5=KZzbsy8YHHqyk@G)q04?9AVj4#55O^D~RhQX&hl4*Q@((S;znd(J zh=_Q?&LSl%Cr2?5p-M&&(%RDW;X^YREId5K7s;uqXxK9XTx_sM=;*vRehV2(0)GPx z^RE-RBq%;pQ$v1{D!LKxaBmO3 zEk~^Y>IaB0G5(+!`G>w5oC(L9Sp$~PQ+Sf%D1MmoJ15zTCAI~$bxF^Mx`pJBMCo3^ zeVLujyqiE1+;Y>y8s|}EYXdiV_{*0sU&p|OAcyB`duJS9N9>iA@!yCE>#&oNk&&10 z1X9>0W6Ia~EIWDuCd$0f+LU^Q7Wc^VWQH%@>!ZWyBVtf-mNquxs*gJ=(W(f!hYzPG zK!v$BR~}%5MJD6uGgR1AP5Ef%Gm3o1L77LDa)yS>+}z&TF)AN#UA!g7o7U^@>dNA9 znZ4}x=EC6MARx>3w&N^!>h%WbJ{Szf;%7S3uJ6ul8zi{@ZWmiY0hHd4`T6);)`eA7 znkFUk)YM5*r^~A=D@;|FNU3eaDwP8!J{J}$<*F?#EHFs>>&npvNQXex-aR--$Pan- zs%KLv0JZ)004ifu6_`Tg>Xw3ZVW@#ToV)dRhkpF{@jB=F@)J!>zyxMyW^_6|85vVt z*Xrsjo-PUHc>0mEzkj1VOLtGtx{JNJ`6qbV*T?pWl9F<4eEcy~!I3X7fP309{oejq z0DTIT=pjEFTV7|UeAI}cu8T`~SlDGU6eRsTRv;dnT2g#G3oGmD4X^zp1gJE3oPlzW z-Oryn;WHq1@PdsT9HgxVSqM>r?M|zGMLJbl+|PY|>vM8)qN0c*+6ah=qa!09qGWQ_ z5)u+>YHL+8#1eYVDTc;v4?%E4B?m)g-}l~?H@1~NFE|tY(JOgSy}>g0*WdS{kwXC< z8yi!VK94_pHHZo$;o{;F5D<`*km#JtyCLe#!pFzQ#T5b8n~zV^>1G{g!W#)S+L^&z zC1GLV;zl$ltbu?4_G8$ke1D}m2*=;wA3(O*A@%t5v@b(k)8zUCoXa9>B$<7@kQGEk zj~_pVc)Es<`%g(oN}{v<$82yji=^bd& zPoUd>)hacH=z~rVhSAcFC)8Rl)6d; zv=TV5bfj*%d&kozJ4cW5jRf7H^OuCfMO6s`W1cx290VZ_7UBOwjbX)c->m`;4vv2$ zIWts7mv6L*buKs;o*7{qlByb?pwY%OhXEHy0iCWA5)#1QuB*cw zSx;UYX=U=N;qZyko`hg#904tlk3H3wun{h%SXe!CJG=1M+gow1-6U>A(AaU`3|#6- z$=M-67|J``0`4r%&zI+>Qet@r?{ixlvGVB75r1rJ%N=Ue5zTOwfB?Oi<^T>8=zJq1 zrpa4U2pQg5pW4-OGAN09$%z2dpmENJrm-Spq93T_XmDowY}QB#faR2aU{8Jj+W zkRvefaayQRvM!H7m3>BLCSw>X1zL&B&1OE^bMyf^uLXw&Rg#*T3flzc(c8Cg>+2;5 zQ4lGUu}ygRhKNwQX1SOfZG5)iZT-%A6seY_N+%Jx;9S$rPA`47jT36b+jLguZhkWm zi)K}5`_&Z+QY2kb3uqWSJ3Asm!q%`igKR{b&~3rrV`4j9kw$i905GHthQu2}R zO-n>XM6J4hgVPm5fE?uP}wLu_gYRGH=Umjj9FD`}}>ksPl z#8)cR2-31&nrMJP@7XgU@PVl!gzW!R-;#@#;Sxlkd>D^&Vx$HeJ9M?}}Cit_WPPfMNV z+J}cVc5TAZA6q)qGx*KA0p%wqCwXG!{f}qD#>*`zOn7h*Z@q4OdM(Ehw#7A#Px$3V zWDg?nQjWGw-?fq7L-+{OVPFQp<#VIdXUT$AYG!7+|AlU_y%|=CuJF;;)Y0CaPOYmI z*wIQ#5sJCU#7LDqY0UB5R{freflj<03V>)9x^{QGMeEku198_ z<(*#-1e;K*RCe(9H=EOS(yLn+dLqlQI9ie}Td@zAW79=l%wg04t^qMIF%}jU7;kd; zW~QdimN|&zQ?hDRaHHD!Y=L-c<66jQv?L?0GeX0s2{;%n>A^9Sup9e4OcgNZNYeWbe!~1hMw_ZVN0@<=9D1!WwXwAmrqr<7L zsGvmgMUi!pB9Bw}qz;RvthNz6oScL5<;HCUgedp*v8)VQ5s@koYBjo;_3}RA21|q% zRs=002#h&^UJ|I&s;U<-w7^TVLwtE>7D=)`H|GNE24xz;%=i3YG`Lp)G186Qe))!g zxiV}IOj`u_`9I`oA-Ju4VG=M6)2duGaQUL~u6zv&IuO{~3G!GU8(1puKbD-&q9j6a zA)po-cKvr6Ni|JTxQKqN|IVQ0xEZ0vzx)P_z6BL7F`iH?6qbU=k6kz9tAsjJl94#eft115_>>z0U?399Zy)eN1v7nec z`1`kZfrj%Haj>zUgXMCvyAW``X3%L{U0W*)4>`Hh)6=I&{4U&NfB0zAyW;HKi@;{$I1bk2)j zr(|Q{h4a2FgUf&cq#zvcn+GfAe$_de66PRiOI{)~*P8riQZO+E9WNJBF)}uJZ0QSx z1H(A5#(A9{+C}}2Bn6hL4Y=@^mwyejArq5ZWdxJv>XSh@V-nvgD*n{E6+$V#bt_ml zhpCV?Rvs1x5Uiu4v*7II=(r7T{PMzrkkf4I?5wdVUbXI5nVbDzd#j=P1j#fnI2W-7 z)|jj7-@+{k4o0nym!nW9xYxScT23x5UfpULp0rI|3RY!#n0WN|!jLZGu1RP_#MR6` z7$mAeOAnYDo0zEcIkGopzGa{v6?f9zttgi9J~p;x!8w-JkUs?sL*r5{Il59Uu(nf_ zKV8$kBG6k<|A5F4T!(~&g~i3t09SW+6?u6YkWi4uEac%*R~&%@!R$D`0LzhxBDw_y_JU}<^Jef?jsMn(<` zU~MjC1~fV^D8jfCWYN>71K(^$*Ecp`R{d5Lr?dotxa9Wle*seNdU+LStil`u+;4EG zznM8hIY$|0Dt)|u~4AsbrlyE7YhrvySe4%~ z6b@_i-x`;&dhN4YVW|7&?G+2T1C|j~cj%=3eF1*d)hYPtJ<|YtS6z4_+P>@fz5Pa- z{Mf5N*7J_hhcvzCE=PS83udKSzzF+$d*Y7MG7lc$@UsDOsuc)A7}u%H9X$dk=*9W0 z)?=?Qx@KfnKTJ#q24a(EVZ6Svafgy%$6LY>8$5)$&cZfnI1p2F+AIp zxFC{x2t$n^V>uHzSy)(Z-VE$DhY`MT8UraQDLFa#L)JDnMJpcH1=9ZAssl{C@`kAI zXwQRDmN(?*>kBB=`k#xiuN>&JUfS{Q-8(Jop-+#MXUU%`)&DEJP3tYt?8W)GqNEp}L{rH0aK+l6uUs z^+4>(tS-H&Lallfy5M4;RKD57J@y*_u{T>uun7`D%)YQ5yl}87=_u&w=$xIM`}>oP zykK_oWdJXfi-SWHoMZk`@!aP_)@DB!7*_3A3N+B*o#uY}B>V7Ti5B;4&86A*OMzD$ zfzJwyiZX=lQp?K)AfJl_HtNX&<0#c$hae(y zn{4cvAA#i6tjqYZj1vY?PsouMNXvI~bO%UlB)XkRkdl zQ`;_5Nzo3PD%MS(*{lUK7&Hx}XDuRfS7rKr+q=&q-Y`|HJbj5(9F(cfChxT$o5Qm= zAjh~tYAv^%W^@M!;XS-cQc)#-(?kzHV>g_dZ(YD|1vlNO7-1zdsgO6}SCOd{YoBt6_D*rbAXL z!q9bXtA6)Q?IHP*oE5&_@|x@JR17&EDSCk1;nvE%5R6Qrgn?BCa*m|fEkBQu@n;Xt z8vl9>=S2)!{IyE@HGIjaemXZ7R}!T0LCT!F8vR>kzp|4XNVwm$;VQ$>rgm;VDri1J|o literal 9635 zcmd6t^;cDY^yV)ic_~RzQfWk58l?pU1O%iJq(hKyln^Nq5$TeW?k?#DDFKmg>F(Kn z<~wW6n!jM~4|>*26Zx8a5iJ2Pl(6vZdae>;W$z%wU8 z4m%MlDJuT1_i)kI|MT_+uH%DO!h?0FhPu3zeG?P0Epr0G!aNj6VPRqakE%JJdzLl@ zf~DT#Er-5YE!JYcTd+B*X^<%HBPOw$Ti9?mAM(JFDG-6dD49O=dwTlv@^W)?lh1VG z*RLWic5-rZ|Bs4{R@T;w`=_F!qJdt|guFX1Eh7oIJ))SD8Pmw=99IWZy@w?cXv2o3 z6Glcxi^aGtMd9K2v5J}MtUiS?6&3jS_}2XKGU>%yd9$;Ic6QtA>+AjfN<0%rTU%Rx zetrxL3{rvENQ}EeLNyf?8nlEI6_YXg?iP>M=zRUKT|GQ7Z`>GKYphI_&-fV_7&tbj z5iOtbLfFP;`uFdPiwiCGFZK0aj*gB`pW-DCJUkV9B@m`*u zsAy|C8mvQ^pO4QaEqut-(P$~`pr`y((izo{_GD=64lTbpplw&x`!CAGK9M9O{B z$mOH-c#rB($#7Vp>|RFcNc%$JAVE8-n*hzb<>AWQU!XlPg2@$(R#Ij4)Bh z-0Sx&i-|`2bT@-tMUwJuk}<_~baXgka#sX>931un@osmnZmz9O6dMO64dJ37dlls5 zu!tYJADEjIoSb;&ef;>b;U;0Mo%P@A;EiwJ?yVPBW@VYJ^e3L4o}QUugf}6Op{hA` zZ&t$Nte*YHfjWsHrP>A6WwV#)I_3D+CRjz8z-@kwJa&iP+wtfq5 z*AY`wi}jvz$x4zm^?bRXUNDw0Lvp)fPrH>atq{S8)Zw?bwjRsZ86O+Vyvsuocwf7$ zvxL9!SyKCktuSziQX3QRj$BCsI1*D>`y&%X?AU zpCH(>vQLV0BY8mF>&(&Gn%yZFMdwQ76cf zmLLKqM#lBdqx}JKpWS)sNV$8_^4rt3l|I*3v$L~9>9T5#%xr85E-&7`dq+SmqNSq) zaqN7^lbDe3^XE_JzrXAc*GJFJ+;h~Sq^Bk)p)ew-MbAdmlr0veuJ?{SBA3_K&)RR> zC5Zbpq^3S*jdpf%86O?>g7C3viNhK#53xYv&XVi9`mF{wYRX1XGn{dxqz$hH;Zyxo zVvp+j!tC!fIVx18g!5UDv(ECux|kf3}C+j$wb1miV>H#1`^;pwQXDLCEWjxBRwknBCA^ zFjLa6MjvtR@0$8W64#R=|%Izm#?rMWpHGqa9`-A_?bxHveSuSPA)laptByK@%^=iu8z zLyzyP%gA8F_btB`$koVKWsP3U3nHLV?TF@g-A0y)amMzpu9~-;`P}+=S2kRw(W~tP z>DdFF-yO?*d6~C#i349|QQY^<>7!5r|vpMq2Gys2&Z7H&vo9Z?TMP<}+e;{$Q_^+V*lcM=h%}g3=2M<{ZAcO;=af z;x&EbD$Vr?rqfpSo0ZP%4hV|n2=n}PR}_<46qTTZ|Jm+r^E-F%TGgD3lZA-$xbt7e z5lf4Ux{coT85z=yQAF{zH8pW@?HwKF)7JxExg=#%c>}GL<>e^@>y24o7XMnUbc@3K zZXuI{Em@|9tP4|DQ%l##?TTpiRloOg*7Ooi?bG#}YTfm?@_?Wqan~JRihFShzn~ix zH#&u0Wv71sE^qt-HB4rz(k@xd%=Qw$ELGeRj}Qx;a$(m3S@2}qRJc7BJ^opymrT~R(E0igm)-d`cYPPUvgQ`8 z-1QBSu_}+C&tJaCWDTRCqB?I*@*(pFH4F7Kym`Cd)$_iyjC*GJgK(3i^n}aS+E!Bs zJBNmEQUU-*{?c`+o79AUW4w@Nz3^j~3619tij%ypt!?3g!LWH*hC%r#w}R zT2ByaKC;2*3H|;3BP@{Dj1P`nMgByyXdJw3hB*Nb`3VZ->p&fSZY3!KwA`a2X5$REB2?Giuovg%9@qOlDL3t-r0;`k zd$_w7T_uQme5qMJp7Eg>R&I^RZ8io3olU4u{&iVEA z^@Unz%kg$^-@etz)7ssvoPk7#dBYany}Y~(Z8n7bf$ek+-~Ibf7i6ln^Yu$ixW%=cBOO4{K^`UvW>o!z2i&5vL|6 zKdx-NwAoo2or5NxE)(Xy-}i8TfB)38L6;+@%LES(53;~xvwU1aLZX+|Jt$5)|4*4k zRAZx$sp&Fw5xbd&`!|C}ES&(7FVBzn_x8pL4QPmn9_yDJS*7sVaPpy0m=7e0j;N6s zH|(PzIB}Gf2WGs_ZCV2Hu(7edy}bb?l$4aB7@v0c^<~J|y?AlU`Zc0ATt?y1KVD~i z8fUVefQrDNZUXp^5Wf}EH$;oL1UZNv=WFgND41D&NpE{gX*)M?1<1lDYY_0eYuJOw;1>qozia0ej$3YN4+U@N*?1>h7 z$2d7V;}a0Lym^y8yaqZJ4#Ueyi%*W9pMPj*2Pl%F5A^cVYvT zWJ2PYJEX01;Ye+Z9 zywyntF){C4-GS$*uZTmnnB^q9Wl?7COJ{W-Uq z;4&8{yOEKRI05r#hZ~@f$NJHuy7CGO$#DW&Bdjzv$tTJ+U0tiB=vmm6K9CkU;6_?giFrEh~ooS?Gw+R9A;#LX>FDLh>P3voQ19w~y1cH$~-$r_I^*Y7Uy; zVFKk51xrD%is!d^$w>i3%SWql2Y&^|11M~mJ|M4P$lBHIwgXGi6Jk+PQZ~R4hyE2M zYWTpQp)Moi)l`K|SXkK2;6*qaQc_Y%uf|I{UvL*Pm6NFn2?r-9-4EBFTUJJ>Qv^Qq zz+PWnef|11?ODarYXNBT2^lKPQuL9Kb&vqn)iAEtH@4xS>YeF@U8T_2Pa``PB8L)oMgA5ZBGm1t*L7}v?G{-5vXXz993dlT& z!~3bGrl!HcL73e)?@A1=IffNngPO{=8f(ll50*+Bsuw=w{kXcBxJva0_!OoZhf+ty znH@q_M&{;J8MqxY6^9M{4~L>|ziUoWf^;59%=ef8bskpC8u(s?`QY#nBEGnsF7qI$ z#ooao%@&ingkft59u>a){yo+ndmLpw5fKpz3W`~O0^PnRkRV`-=Ux{Rs3O&zvZA7o ztCqQ0S+d4Oa?s`U&sW#CwlXVjd?dFLs!%kT`d(V9majo0?tKA?Ak=+w&f=)5`UylA z2{|RDQ0_x7ycWq(m;+;D%S%hrO2wt63l7z1jEUq>l?m#1e|ZnZQtG>oA5Vo0T`kdTxA6TZ}L2ge9g z?%1Qtp;DK@X2z(s*;!6-4v;Op?e^rBPi`Q>GphTm6Oo~W3VPWN!f{)O=aRnphh z75x4^ZI-N*6c|md?d{%YYgr>qpQ*(>wVpkLv9q?e_Jm6OaA&Rs2>8W|7XVHj9V^o= zGtYBJMy{6g^Q)4;~g4 zVMrVFk)Pfl7UtVRpy7~#-DgH)`@3rQXv*$^cG>Dkj#{hNY+fF_B@guzT0-kTb%H!R zZ-8b&+0l5P%-y!DGkU$e{ihxPe@{AuZ1Ye3RCBZ6z={FbQU`0pBfO2tUH2Fm4n2IM zdzKjQbQ-UQHetS#1^AFWW#|3JD>6wl%BeY(v-!c{&d-?lEk)Np^AZnQr_>bht=zvfa8ag0pNDi6NQpL9&se!ko7kgzbwW61=ps5@hrX zj4ovpAwUUSat;xv_0MwmesJcEljopN#D-o-T&oDUZ0SNi=Db1i6)f>ZEbpJfz#8c3 zG3KPG5k5gR{8sFX0Gh677f1L$t`Fk{&+Eqz39x&k<-zY8E6~fGW{KG1g#kSHZ z$E{3Ffl^as*mM83;`%*(9g;ZlEfyR4ye7xdN4Fi@KCx9XlYDnptR|;WFp`oXj z`03L;xL%Z$?iVNa$QE=|l%dE*3@k$g#_cBA@H@?%*6_8-ROxWlRAu;y;Lf%)-~NY6 zfkqD+FG`$EX?;;B{_^dd>@E;VQ06zA4J6rESYYGe&T&Q#7%HiSRqOR9Z!m?pretyeuCYHdJDDL@J0sEKI(gBcTO@!}kU!fG~@*i){ z{A)BNCQrbne)FbOJ74BYXtSXRAmeu3zLLHDE;!{+6R8k^WXSw~%d7u+^-}Y`L@}H* z^fW}UlZter_}+N8>xWWg$SKB&_=4MxiP7{n_WY!Yoou6eCFy)Uw-9Vk=tpfY?H@mG zW-F|dNWZMSyyHvM=POnmI2^}k<-SQ6vU;uea5WN5OZ3flq$F!Jm{+EM>fGk$=EB3n zVT%J`B8m0N#H7hQ9r5*?s7e#u!5^H~kghdR%|sMhq9J!^s}g^`aSyvYUGv z{@j|b}CoQZ-G!KDd7%~ z*!^2NfZt$_&_W5hMe}QTc>jF6kpLGL#PIc2?KVc74I=N;t5AZ!8yg#9Ln)zHF zj-&PQAjY~;+cBy6EY%@At3&BL+}tTyS_pOv&skUqfGUfYk4{Po$G|G^^E>3(-}M9f z{IgLy*ebf%OoE4CLoA@78Fu}58Zp(4Vd#iKl>g44`J@S<#l5OQhUmHR@4qw~nv?#Y zBmDpNZ{Js?TuvCiq%Y@I9c$z>B<>=59G5ci_K#86WYUK`Jw5*|R`Tz2NoS~nfeO1C zWXs3V)Y!*vu*|bB`fmkD7Q*n~jsh^JwDgU$^YX4McWho}rVL}$i{T2>SXsAof>|K6 z=eZhs4W6en*H;g@Z-ZF`0xSeJi~r3&aALT|;`)l}*vZ&n|HIEe*3%QxY*NZb} zcINOJv{DA~pXTi%5u7c1VwVTQU;r%2ibMaufEM3-FA!FPTI^$+QxYs-mv$-;02y9ZtYxdF1)bxDCb=7>NOjCMP|)_S^=~ zo;4eCg17D}Ld}iFPSyjl3#n2SJJKjEozxC~0^aIqK_jlD!N% zR`=ub|6SC86Z$dKBPc15Z@dp5f<-5CeX$SSgN20!h6-HT2=pwxgTgc5LWYEd#Dhmw zIv}Q_t7~oh@Md3XouiPbpx_H;k}JTRP9&k*m=~R7FJEQ7#f_!Q`esv3xRWdu=I}d$ zdpmQIKp5a69IQU8))fOd6(uJ0zB*k24uf?u7)@kkWcszvec%Q#X7aI`ZDX@;Tig?G(N1&Ti!~x=;<*n)y&hH+um-3B@q~tCQ5MqSKKck zkDU`4w?ev2xJ%F4isSb1`F*A7BO@XrqN0ce+d#H-bjaSjNpx5>gYM5|+^(Xj;7JlJ z1*i1APK5daX!^FcS2GP>o?O)!@KDM6=<^*%^m!6!)Iv&oz#>6TR3a&tUL6=bDCFNw^oj1C#3~I|fE9yQ zhzg5@s(&2enL$4Ka>lJx0>T=9S-Zkp8FvrZB~}P6TSL@83|f7z=GmRS4jEHzq&*-M zqC#gBVkg?$4L17B;*8~W-Ju|dq~nq>reQFoRZoeYWimOEz9bOiDyY1>gPy-wg}Bb2rl2ndQ^Jn!b102ou7^TO`v86B!jk=CX3|s-toks*-7<#2!IhI# z;mDHCT&x^IApu=!#G`bJFDF(~tJgISKQ2EgkC+Py2O5aqOjL=U^-DPojXotOl{8;S zN59lZ6AxmNb32F(i8<{|k2u0Ar#tBT_yq+aYI%sMt&>w5(H$`-%T3?x#4}Sd>C)>h zVa~pZX|#vnMjPPWk4Un*%gqA=RF-iuCozZwv9OQ_}s*e zsW&#vsI^z3rhc>412>-${8ZffM5U{hXQ+%4;@a~z_o%W4Y0RTn*z(oP z=jY0uJ9G0CPb=tMa!M@`eR3Uk za-YHyH*#s>{h)@w-Phz2S-U=BG+RhiMChE^h2VXJZ$Po6IiB~`g?MH^ e_M None: + """Drive one LED tick with a synthetic transport phase. The LCD reads the + phase from the handler's last tick rather than the grid, so this is the + only way in.""" + handler._drive_footswitch_leds( + TickState( + is_anchored=True, + is_flashing=False, + is_bar_start=False, + bpm=120.0, + bpb=4.0, + beat_phase=0.0, + bar_phase=bar_phase, + ) + ) + + +def test_progress_border_fills_by_bar_and_beat(v3_system: SystemFixture, make_parameter, snapshot): + """A 4-bar loop playing bar 3, half a bar in, fills 5/8 of the perimeter -- + with a notch at each bar boundary. Loop 2 is stopped: full ring, no fill.""" + handler = v3_system.handler + lcd = handler.lcd + plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")] + _bind(v3_system, plugins) + + lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches) + lcd.draw_main_panel() + + for inst, state in (("loopjefe_1", 4.0), ("loopjefe_2", 5.0)): # Playback, Stopped + v3_system.ws_bridge.inject(f"output_set /graph/{inst} state {state}") + v3_system.ws_bridge.inject(f"output_set /graph/{inst} loop_bars 4.0") + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0") + handler.poll_ws_messages() + + _beat(handler, 0.5) + lcd.update_footswitches() + snapshot("play-bar3-beat3") + + +def test_progress_border_chases_while_recording(v3_system: SystemFixture, make_parameter, snapshot): + """The first take has no length to be a fraction of, so the border sweeps a + head instead of filling. Loop 2 is empty: no border at all.""" + handler = v3_system.handler + lcd = handler.lcd + plugins = [_loopjefe(make_parameter, "loopjefe_1"), _loopjefe(make_parameter, "loopjefe_2")] + _bind(v3_system, plugins) + + lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches) + lcd.draw_main_panel() + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 2.0") # Recording + handler.poll_ws_messages() + + _beat(handler, 0.25) + lcd.update_footswitches() + snapshot("recording-chase") + + +def test_overdub_past_declared_length_chases(v3_system: SystemFixture, make_parameter): + """An overdub that outruns the head loop's bar count has no denominator + left, so it degrades to the chaser rather than filling past 100%.""" + handler = v3_system.handler + plugins = [_loopjefe(make_parameter, "loopjefe_1")] + _bind(v3_system, plugins) + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 7.0") # Overdub + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0") + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0") + handler.poll_ws_messages() + _beat(handler, 0.0) + + fs = v3_system.hw.footswitches[0] + assert handler.footswitch_loop_progress(fs) == LoopProgress(LoopFill.FILL, (255, 140, 0), 4, 0.5) + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 4.0") + handler.poll_ws_messages() + _beat(handler, 0.0) + assert handler.footswitch_loop_progress(fs) == LoopProgress(LoopFill.CHASE, (255, 140, 0), 0, 0.0) diff --git a/uilib/footswitch.py b/uilib/footswitch.py index ea75b8458..c017d1ccb 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -23,9 +23,11 @@ import pygame +from common.loop_progress import LoopFill, LoopProgress from uilib.box import Box from uilib.config import Color, Config from uilib.glyphs import CircleGlyph, RingGlyph +from uilib.glyphs.perimeter_progress import PerimeterProgressGlyph from uilib.glyphs.tint import tint_mask from uilib.misc import InputEvent, get_text_size from uilib.paint import PaintContext @@ -61,6 +63,16 @@ def get_bpm(self) -> float: ... # Title white — same (255,255,255) used for pedalboard/snapshot titles. TITLE_WHITE: Color = (255, 255, 255) +# Loop progress border: inset 1px all round, same radius/weight as the tap +# border so the two views read as the same frame. +PROGRESS_INSET = 1 +PROGRESS_RADIUS = 5 +PROGRESS_THICKNESS = 2.0 +PROGRESS_GAP = 3.0 # notch between bars, px of arclength +CHASE_SPAN = 0.18 # turns of perimeter the indeterminate head covers +# The unfilled track, as a fraction of the state colour. +TRACK_DIM = 0.28 + class FootswitchWidget(Widget): """Footswitch indicator: a colored dot (the "LED") with a label below, @@ -100,7 +112,9 @@ class FootswitchWidget(Widget): is_bypassed: bool taptempo: TapTempoProtocol | None state_label: str | None + progress_fn: Callable[[], LoopProgress | None] | None _pulse_on: bool + _progress: LoopProgress | None def __init__( self, @@ -111,6 +125,7 @@ def __init__( small_font: pygame._freetype.Font | None = None, taptempo: TapTempoProtocol | None = None, state_label: str | None = None, + progress_fn: Callable[[], LoopProgress | None] | None = None, **kwargs, ): self._init_attrs(Widget.INH_ATTRS, kwargs) @@ -123,7 +138,10 @@ def __init__( self.is_bypassed = is_bypassed self.taptempo = taptempo self.state_label = state_label + self.progress_fn = progress_fn self._pulse_on = True + self._progress = None + self._progress_key: tuple[int, int, int] | None = None def _tap_active(self) -> bool: return self.taptempo is not None and self.taptempo.is_enabled() @@ -193,6 +211,7 @@ def _draw_state(self, ctx: PaintContext, w: int) -> None: """Two-line view for a switch whose plugin publishes a state: name on top, state below in the state's own color. No dot — the coloured state word is the indicator, and 36px holds two lines or a dot, not both.""" + self._draw_progress(ctx, w, ctx.height) font = self._slot_font() name = self._fit(self.label or "", w - 2, font) @@ -257,10 +276,72 @@ def refresh(self, box=None): else: super().refresh(box) + + def _progress_glyph(self, w: int, h: int) -> PerimeterProgressGlyph: + return PerimeterProgressGlyph( + w - 2 * PROGRESS_INSET, h - 2 * PROGRESS_INSET, PROGRESS_RADIUS, PROGRESS_THICKNESS + ) + + def _draw_progress(self, ctx: PaintContext, w: int, h: int) -> None: + """The loop's position around the slot's border: one arc per bar, the + elapsed part in the state colour over a dim track of the same hue.""" + progress = self._progress + if progress is None or w <= 2 * PROGRESS_RADIUS or h <= 2 * PROGRESS_RADIUS: + return + + glyph = self._progress_glyph(w, h) + ox, oy = ctx._f().topleft + at = (PROGRESS_INSET + ox, PROGRESS_INSET + oy) + r, g, b = progress.color + dim: Color = (int(r * TRACK_DIM), int(g * TRACK_DIM), int(b * TRACK_DIM)) + + if progress.mode is LoopFill.STATIC: + ctx.surface.blit(tint_mask(glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP), dim), at) + return + + if progress.mode is LoopFill.CHASE: + head = glyph.render(progress.position, progress.position + CHASE_SPAN) + ctx.surface.blit(tint_mask(head, progress.color), at) + return + + ctx.surface.blit(tint_mask(glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP), dim), at) + filled = glyph.render(0.0, progress.position, progress.segments, PROGRESS_GAP) + ctx.surface.blit(tint_mask(filled, progress.color), at) + + def poll_progress(self) -> bool: + """Re-read the loop position; True when the drawn result would differ. + + Quantised to whole perimeter pixels — the position advances + continuously but the border can only move a pixel at a time, and each + step costs a slot repaint.""" + if self.progress_fn is None: + return False + progress = self.progress_fn() + if progress is None: + changed = self._progress is not None + self._progress, self._progress_key = None, None + return changed + + box = self.box + w = box.width if box is not None else 0 + h = box.height if box is not None else 0 + if w <= 2 * PROGRESS_RADIUS or h <= 2 * PROGRESS_RADIUS: + return False + steps = self._progress_glyph(w, h).perimeter + key = (progress.mode.value, progress.segments, int(progress.position * steps)) + + self._progress = progress + if key == self._progress_key: + return False + self._progress_key = key + return True + def tick(self) -> None: """Blink the tap border at tempo, phase-locked to the last tap.""" taptempo = self.taptempo if taptempo is None or not taptempo.is_enabled(): + if self.poll_progress(): + self.refresh() return bpm = taptempo.get_bpm() if not bpm: diff --git a/uilib/glyphs/perimeter_progress.py b/uilib/glyphs/perimeter_progress.py new file mode 100644 index 000000000..ffdd3571c --- /dev/null +++ b/uilib/glyphs/perimeter_progress.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with pi-stomp. If not, see . + +"""Rounded-rectangle perimeter used as a progress track. + +Renders an **alpha mask** (white RGB, coverage in alpha) of the arc from +`start` to `end` measured as arclength around a rounded rect, clockwise from +top-centre. `segments` notches the track at each 1/n boundary, so a 4-bar loop +reads as four arcs rather than one continuous stroke. + +Geometry is cached per (size, radius, thickness); `render` is not — a mask is +~2700 pixels of numpy and a progress sweep visits every pixel step, so caching +the masks would cost megabytes to save microseconds. + +Unlike the disc glyphs this is sized to a box, not a radius: blit at the box's +top-left, no centring offset. +""" + +from __future__ import annotations + +import math +from functools import lru_cache + +import numpy as np +import pygame + +_TWO_PI = 2.0 * math.pi + + +@lru_cache(maxsize=16) +def _geometry(width: int, height: int, radius: int, thickness: float) -> tuple[np.ndarray, np.ndarray, float]: + """(coverage, t, perimeter) for a rounded-rect outline. + + `coverage` is the anti-aliased stroke band; `t` is each pixel's position + in [0, 1) along the path, taken at the nearest point on it. Pixels well + inside the shape have no nearest point in any meaningful sense, but their + coverage is 0, so `t` there is arbitrary rather than wrong. + """ + a = width / 2.0 + b = height / 2.0 + r = min(float(radius), a, b) + ex = a - r # half-length of the horizontal straights + ey = b - r + + quarter = 0.5 * math.pi * r + perimeter = 4.0 * ex + 4.0 * ey + _TWO_PI * r + + x = np.arange(width, dtype=float) + 0.5 + y = np.arange(height, dtype=float) + 0.5 + X, Y = np.meshgrid(x, y) + dx = X - a + dy = Y - b + + # Nearest point on the inner rect; the path is that point pushed out by r. + ix = np.clip(dx, -ex, ex) + iy = np.clip(dy, -ey, ey) + vx = dx - ix + vy = dy - iy + dist = np.hypot(vx, vy) + coverage = np.clip(thickness / 2.0 + 0.5 - np.abs(dist - r), 0.0, 1.0) + + # Arclength offsets, clockwise from top-centre: the top edge is split so + # t=0 lands mid-top rather than on a corner. + o_tr_arc = ex + o_right = o_tr_arc + quarter + o_br_arc = o_right + 2.0 * ey + o_bottom = o_br_arc + quarter + o_bl_arc = o_bottom + 2.0 * ex + o_left = o_bl_arc + quarter + o_tl_arc = o_left + 2.0 * ey + o_top_left = o_tl_arc + quarter + + theta = np.arctan2(vy, vx) + past_x = np.abs(dx) > ex + past_y = np.abs(dy) > ey + right = dx > 0 + below = dy > 0 + + s = np.where(right, dx, o_top_left + ex + dx) # top edge, split at centre + s = np.where(past_y & below, o_bottom + (ex - dx), s) + s = np.where(past_x & ~past_y & right, o_right + (dy + ey), s) + s = np.where(past_x & ~past_y & ~right, o_left + (ey - dy), s) + s = np.where(past_x & past_y & right & ~below, o_tr_arc + r * (theta + 0.5 * math.pi), s) + s = np.where(past_x & past_y & right & below, o_br_arc + r * theta, s) + s = np.where(past_x & past_y & ~right & below, o_bl_arc + r * (theta - 0.5 * math.pi), s) + s = np.where(past_x & past_y & ~right & ~below, o_tl_arc + r * (theta + math.pi), s) + + return coverage, np.mod(s / perimeter, 1.0), perimeter + + +class PerimeterProgressGlyph: + """Progress track around a rounded rect. `render()` returns an alpha mask + the size of the box; blit it at the box's top-left.""" + + def __init__(self, width: int, height: int, radius: int, thickness: float = 2.0) -> None: + self._width = int(width) + self._height = int(height) + self._radius = int(radius) + self._thickness = float(thickness) + + @property + def perimeter(self) -> float: + """Path length in pixels — the natural quantum for a progress step.""" + return _geometry(self._width, self._height, self._radius, self._thickness)[2] + + def render(self, start: float, end: float, segments: int = 0, gap: float = 3.0) -> pygame.Surface: + """Mask of the arc [start, end] in turns, wrapping past 1. + + `segments` > 1 opens a `gap`-pixel notch at every 1/segments boundary. + """ + coverage, t, perimeter = _geometry(self._width, self._height, self._radius, self._thickness) + + span = end - start + if span >= 1.0: + select = np.ones_like(t) + elif span <= 0.0: + select = np.zeros_like(t) + else: + behind = np.mod(t - start, 1.0) + select = np.clip((span - behind) * perimeter + 0.5, 0.0, 1.0) + if start != 0.0: + select = select * np.clip(behind * perimeter + 0.5, 0.0, 1.0) + + if segments > 1: + to_boundary = np.abs(np.mod(t * segments + 0.5, 1.0) - 0.5) * perimeter / segments + select = select * np.clip(to_boundary - gap / 2.0 + 0.5, 0.0, 1.0) + + alpha = np.clip(coverage * select * 255.0, 0.0, 255.0).astype(np.uint8) + + surf = pygame.Surface((self._width, self._height), pygame.SRCALPHA) + pixels = pygame.surfarray.pixels3d(surf) + pixels[:, :, 0] = 255 + pixels[:, :, 1] = 255 + pixels[:, :, 2] = 255 + del pixels + pa = pygame.surfarray.pixels_alpha(surf) + pa[:] = alpha.T + del pa + return surf From 6ac259266c198bb9dc7661ece959dd6f256c0fc1 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 19 Aug 2026 00:02:10 -0400 Subject: [PATCH 11/18] Fix websocket thing --- modalapi/websocket_bridge.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modalapi/websocket_bridge.py b/modalapi/websocket_bridge.py index cb3a304db..7026bad11 100644 --- a/modalapi/websocket_bridge.py +++ b/modalapi/websocket_bridge.py @@ -31,6 +31,7 @@ from typing import Optional import websockets +import websockets.exceptions # lazy __getattr__ aliases the API, not the submodules import uvloop from common.parameter import Symbol From bf83160ea351bf297dcab2ee3674e4a139262de5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 19 Aug 2026 00:16:13 -0400 Subject: [PATCH 12/18] Don't tap when not open --- common/loop_progress.py | 1 + modalapi/led_render.py | 29 ++++++++++++--- modalapi/modhandler.py | 29 ++++++++++----- tests/v3/test_footswitch_state_view.py | 51 ++++++++++++++++++++++++-- tests/v3/test_taptempo_led.py | 32 ++++++++++++++-- uilib/footswitch.py | 17 +++++++-- 6 files changed, 133 insertions(+), 26 deletions(-) diff --git a/common/loop_progress.py b/common/loop_progress.py index 1d269ab44..5b39b2e54 100644 --- a/common/loop_progress.py +++ b/common/loop_progress.py @@ -40,3 +40,4 @@ class LoopProgress: color: tuple[int, int, int] segments: int # bars in the loop; 0 when the length isn't known yet position: float = 0.0 # turns, [0, 1) + pulse: float = 1.0 # brightness of the lit part; the beat envelope, 1.0 when free-running diff --git a/modalapi/led_render.py b/modalapi/led_render.py index f3e3ce452..0c250e948 100644 --- a/modalapi/led_render.py +++ b/modalapi/led_render.py @@ -32,6 +32,9 @@ from modalapi.plugin_customization import LedSpec +_BEAT_DECAY = 0.7 + + class LedDisplayStyle(Enum): SOLID = auto() METRONOME = auto() @@ -56,6 +59,15 @@ def render_led_spec( return base, style +def metronome_brightness(beat_phase: float, is_bar_start: bool) -> float: + """The per-beat brightness envelope: full on the downbeat, decaying across + the beat. Shared by the physical LED and the LCD's progress border so a + slot and its switch never pulse out of step.""" + if is_bar_start: + return 1.0 + return 1.0 - beat_phase * _BEAT_DECAY + + def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None: """Short display name for the current state, or None when the plugin declares no `labels`. Same lookup as `render_led_spec`, for the LCD.""" @@ -64,16 +76,23 @@ def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None: return spec.labels.get(int(output_values.get(spec.state_symbol, 0))) -def loop_progress(spec: LedSpec, output_values: dict[str, float], bar_phase: float) -> LoopProgress | None: +def loop_progress( + spec: LedSpec, + output_values: dict[str, float], + bar_phase: float, + beat_brightness: float = 1.0, +) -> LoopProgress | None: """Where the plugin is through its loop, or None if it has no loop to be through. `bar_phase` interpolates within the current bar — the plugin only publishes a bar index, and a per-sample position port would be a monitored - output changing every process cycle.""" + output changing every process cycle. `beat_brightness` is the envelope from + `metronome_brightness`, applied only to the states the spec says pulse.""" if spec.bars_symbol is None or spec.downbeat_symbol is None: return None - color, _style = render_led_spec(spec, output_values) + color, style = render_led_spec(spec, output_values) if color is None: return None + pulse = beat_brightness if style is LedDisplayStyle.METRONOME else 1.0 state = int(output_values.get(spec.state_symbol, 0)) bars = int(output_values.get(spec.bars_symbol, 0)) @@ -82,9 +101,9 @@ def loop_progress(spec: LedSpec, output_values: dict[str, float], bar_phase: flo # Past the length it declared (an overdub that outgrew the head loop) is # the same situation as a take still recording: a position, no denominator. if state in spec.chase_states or (bars > 0 and measure >= bars): - return LoopProgress(LoopFill.CHASE, color, 0, bar_phase) + return LoopProgress(LoopFill.CHASE, color, 0, bar_phase, pulse) if bars <= 0: return None if state in spec.steady_states: return LoopProgress(LoopFill.STATIC, color, bars) - return LoopProgress(LoopFill.FILL, color, bars, (measure + bar_phase) / bars) + return LoopProgress(LoopFill.FILL, color, bars, (measure + bar_phase) / bars, pulse) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index aab7b6827..69b3cb3f1 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -77,7 +77,7 @@ from plugins.customization import patch_extra_data import modalapi.external_midi as ExternalMidi from common.loop_progress import LoopProgress -from modalapi.led_render import LedDisplayStyle, loop_progress, render_led_spec +from modalapi.led_render import LedDisplayStyle, loop_progress, metronome_brightness, render_led_spec from modalapi.ethernet import EthernetManager from modalapi.jack_mute import JackMute from pistomp.lcd320x240 import Lcd @@ -654,18 +654,20 @@ def _drive_footswitch_leds(self, beat: TickState | None = None) -> None: 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.""" + """Built-in renderer for the taptempo footswitch: while tap tempo mode is + enabled, flashes from whichever beat source is active — transport-anchored + beat grid, or taptempo.anchor + bpm blink when unanchored — else falls + back to the default per-footswitch renderer.""" + if fs.taptempo is None or not fs.taptempo.is_enabled(): + return self._render_footswitch(fs, beat) if beat.is_anchored: if beat.is_flashing: return (_METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB), LedDisplayStyle.SOLID return None, LedDisplayStyle.SOLID - # Unanchored: if taptempo is enabled with a bpm, blink from the taptempo + # Unanchored: 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: + if fs.taptempo.get_bpm() > 0: now_s = _now_us() / 1_000_000.0 period = 60.0 / fs.taptempo.get_bpm() elapsed = now_s - fs.taptempo.anchor @@ -673,7 +675,7 @@ def _render_taptempo( if phase_in_beat < 0.1: # 100ms on-window return _METRONOME_BEAT_RGB, LedDisplayStyle.SOLID return None, LedDisplayStyle.SOLID - # Taptempo disabled or no bpm yet: fall through to the default renderer. + # No bpm yet: fall through to the default renderer. return self._render_footswitch(fs, beat) def _render_footswitch( @@ -699,7 +701,14 @@ def footswitch_loop_progress(self, fs: Footswitch) -> LoopProgress | None: if spec is None: return None beat = self._last_beat - return loop_progress(spec, plugin.output_values, beat.bar_phase if beat is not None else 0.0) + if beat is None or not beat.is_anchored: + return loop_progress(spec, plugin.output_values, 0.0) + return loop_progress( + spec, + plugin.output_values, + beat.bar_phase, + metronome_brightness(beat.beat_phase, beat.is_bar_start), + ) def _bound_plugin(self, fs: Footswitch): if fs.parameter is None or self._current is None: @@ -721,7 +730,7 @@ def _write_led( 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) + brightness = metronome_brightness(beat.beat_phase, beat.is_bar_start) color = (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) if color is None: if fs.pixel is not None: diff --git a/tests/v3/test_footswitch_state_view.py b/tests/v3/test_footswitch_state_view.py index c335ae3c8..0e578919a 100644 --- a/tests/v3/test_footswitch_state_view.py +++ b/tests/v3/test_footswitch_state_view.py @@ -93,7 +93,7 @@ def test_state_view_repaints_on_output_set(v3_system: SystemFixture, make_parame snapshot("overdub-from-output-set") -def _beat(handler, bar_phase: float) -> None: +def _beat(handler, bar_phase: float, beat_phase: float = 0.0, is_bar_start: bool = False) -> None: """Drive one LED tick with a synthetic transport phase. The LCD reads the phase from the handler's last tick rather than the grid, so this is the only way in.""" @@ -101,10 +101,10 @@ def _beat(handler, bar_phase: float) -> None: TickState( is_anchored=True, is_flashing=False, - is_bar_start=False, + is_bar_start=is_bar_start, bpm=120.0, bpb=4.0, - beat_phase=0.0, + beat_phase=beat_phase, bar_phase=bar_phase, ) ) @@ -171,3 +171,48 @@ def test_overdub_past_declared_length_chases(v3_system: SystemFixture, make_para handler.poll_ws_messages() _beat(handler, 0.0) assert handler.footswitch_loop_progress(fs) == LoopProgress(LoopFill.CHASE, (255, 140, 0), 0, 0.0) + + +def test_progress_border_pulses_with_the_beat(v3_system: SystemFixture, make_parameter): + """The border carries the same envelope as the physical LED -- full on the + downbeat, decaying across the beat -- so the slot and its switch never + pulse out of step. Only the states the spec says pulse are affected.""" + handler = v3_system.handler + plugins = [_loopjefe(make_parameter, "loopjefe_1")] + _bind(v3_system, plugins) + fs = v3_system.hw.footswitches[0] + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0") # Playback + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0") + handler.poll_ws_messages() + + _beat(handler, 0.0, beat_phase=0.0, is_bar_start=True) + downbeat = handler.footswitch_loop_progress(fs) + _beat(handler, 0.2, beat_phase=0.8) + late = handler.footswitch_loop_progress(fs) + assert downbeat is not None and late is not None + assert downbeat.pulse == 1.0 + assert late.pulse < downbeat.pulse + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 5.0") # Stopped: steady + handler.poll_ws_messages() + _beat(handler, 0.2, beat_phase=0.8) + stopped = handler.footswitch_loop_progress(fs) + assert stopped is not None and stopped.pulse == 1.0 + + +def test_progress_border_is_steady_without_transport(v3_system: SystemFixture, make_parameter): + """No beat_sync, no envelope -- an unanchored grid must not leave the + border stuck at whatever brightness the last phase happened to be.""" + handler = v3_system.handler + plugins = [_loopjefe(make_parameter, "loopjefe_1")] + _bind(v3_system, plugins) + + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0") + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0") + handler.poll_ws_messages() + handler.beat_grid.clear() + handler._drive_footswitch_leds() + + progress = handler.footswitch_loop_progress(v3_system.hw.footswitches[0]) + assert progress is not None and progress.pulse == 1.0 diff --git a/tests/v3/test_taptempo_led.py b/tests/v3/test_taptempo_led.py index 781c647f6..302e6b0b9 100644 --- a/tests/v3/test_taptempo_led.py +++ b/tests/v3/test_taptempo_led.py @@ -6,7 +6,8 @@ - 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. + - Taptempo disabled: the footswitch behaves as a default toggle — no + metronome flash, whether or not the transport is anchored. The gpiozero hardware blink() is gone — the 10ms driver tick computes on/off from the taptempo phase, same as it does for the transport-anchored case. @@ -33,13 +34,20 @@ def _mock_fs(fs): fs.led = MagicMock() +def _enable_tap(fs): + assert fs.taptempo is not None + fs.taptempo.enable(True) + + class TestTransportAnchored: - """When beat_grid is anchored (beat_sync received), the taptempo footswitch - flashes beat-synced from the transport — same as the old _drive_metronome.""" + """When beat_grid is anchored (beat_sync received) and tap tempo mode is on, + the taptempo footswitch flashes beat-synced from the transport — same as the + old _drive_metronome.""" def test_flashing_beat_shows_beat_color(self, v3_system: SystemFixture): fs = _find_taptempo_fs(v3_system) _mock_fs(fs) + _enable_tap(fs) beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0) v3_system.handler._drive_footswitch_leds(beat) @@ -51,6 +59,7 @@ def test_flashing_beat_shows_beat_color(self, v3_system: SystemFixture): def test_flashing_bar_start_shows_downbeat_color(self, v3_system: SystemFixture): fs = _find_taptempo_fs(v3_system) _mock_fs(fs) + _enable_tap(fs) beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=True, bpm=120.0, bpb=4.0, beat_phase=0.0) v3_system.handler._drive_footswitch_leds(beat) @@ -59,6 +68,7 @@ def test_flashing_bar_start_shows_downbeat_color(self, v3_system: SystemFixture) def test_not_flashing_turns_off(self, v3_system: SystemFixture): fs = _find_taptempo_fs(v3_system) _mock_fs(fs) + _enable_tap(fs) beat = TickState(is_anchored=True, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.5) v3_system.handler._drive_footswitch_leds(beat) @@ -152,4 +162,18 @@ def test_no_gpiozero_blink_called(self, v3_system: SystemFixture): 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 + fs.led.blink.assert_not_called() # type: ignore[unionAttr] + +def test_anchored_transport_does_not_flash_when_disabled(v3_system: SystemFixture): + """Transport anchored but tap tempo mode off → no metronome flash; the + switch renders from its own binding like any other.""" + fs = _find_taptempo_fs(v3_system) + _mock_fs(fs) + assert fs.taptempo is not None + fs.taptempo.enable(False) + fs.toggled = True + v3_system.handler._drive_footswitch_leds( + TickState(is_anchored=True, is_flashing=False, is_bar_start=False, + bpm=120.0, bpb=4.0, beat_phase=0.5) + ) + fs.pixel.set_enable.assert_called_once_with(True) # the flash would have blanked it diff --git a/uilib/footswitch.py b/uilib/footswitch.py index c017d1ccb..2c228c170 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -70,6 +70,7 @@ def get_bpm(self) -> float: ... PROGRESS_THICKNESS = 2.0 PROGRESS_GAP = 3.0 # notch between bars, px of arclength CHASE_SPAN = 0.18 # turns of perimeter the indeterminate head covers +PULSE_STEPS = 8 # brightness quantisation; each step past this is a slot repaint # The unfilled track, as a fraction of the state colour. TRACK_DIM = 0.28 @@ -141,7 +142,7 @@ def __init__( self.progress_fn = progress_fn self._pulse_on = True self._progress = None - self._progress_key: tuple[int, int, int] | None = None + self._progress_key: tuple[int, int, int, int] | None = None def _tap_active(self) -> bool: return self.taptempo is not None and self.taptempo.is_enabled() @@ -293,6 +294,9 @@ def _draw_progress(self, ctx: PaintContext, w: int, h: int) -> None: ox, oy = ctx._f().topleft at = (PROGRESS_INSET + ox, PROGRESS_INSET + oy) r, g, b = progress.color + # Only the lit part carries the beat envelope -- a track that breathed + # with it would read as the whole slot flickering. + lit: Color = (int(r * progress.pulse), int(g * progress.pulse), int(b * progress.pulse)) dim: Color = (int(r * TRACK_DIM), int(g * TRACK_DIM), int(b * TRACK_DIM)) if progress.mode is LoopFill.STATIC: @@ -301,12 +305,12 @@ def _draw_progress(self, ctx: PaintContext, w: int, h: int) -> None: if progress.mode is LoopFill.CHASE: head = glyph.render(progress.position, progress.position + CHASE_SPAN) - ctx.surface.blit(tint_mask(head, progress.color), at) + ctx.surface.blit(tint_mask(head, lit), at) return ctx.surface.blit(tint_mask(glyph.render(0.0, 1.0, progress.segments, PROGRESS_GAP), dim), at) filled = glyph.render(0.0, progress.position, progress.segments, PROGRESS_GAP) - ctx.surface.blit(tint_mask(filled, progress.color), at) + ctx.surface.blit(tint_mask(filled, lit), at) def poll_progress(self) -> bool: """Re-read the loop position; True when the drawn result would differ. @@ -328,7 +332,12 @@ def poll_progress(self) -> bool: if w <= 2 * PROGRESS_RADIUS or h <= 2 * PROGRESS_RADIUS: return False steps = self._progress_glyph(w, h).perimeter - key = (progress.mode.value, progress.segments, int(progress.position * steps)) + key = ( + progress.mode.value, + progress.segments, + int(progress.position * steps), + int(progress.pulse * PULSE_STEPS), + ) self._progress = progress if key == self._progress_key: From db70126cd451e694b96df40d71b48522a16d4e87 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 19 Aug 2026 00:54:38 -0400 Subject: [PATCH 13/18] Loop icons --- modalapi/plugin_customization.py | 2 + pistomp/lcd320x240.py | 37 ++++-- plugins/loopjefe/__init__.py | 3 +- tests/pedalboard_fixtures.py | 5 + .../recording-chase.png | Bin 10062 -> 9993 bytes .../play-bar3-beat3.png | Bin 10438 -> 11135 bytes .../overdub-from-output-set.png | Bin 10047 -> 10092 bytes .../empty.png | Bin 9564 -> 9542 bytes .../recording-and-playback.png | Bin 9658 -> 10317 bytes tests/v3/test_footswitch_state_view.py | 4 +- uilib/__init__.py | 3 +- uilib/footswitch.py | 33 ++++- uilib/glyphs/__init__.py | 3 +- uilib/glyphs/loop_icon.py | 122 ++++++++++++++++++ uilib/text.py | 29 ++++- 15 files changed, 218 insertions(+), 23 deletions(-) create mode 100644 uilib/glyphs/loop_icon.py diff --git a/modalapi/plugin_customization.py b/modalapi/plugin_customization.py index 03967d9d4..90a928dc0 100644 --- a/modalapi/plugin_customization.py +++ b/modalapi/plugin_customization.py @@ -106,6 +106,8 @@ class PluginCustomization: tile_border: RectBorder | None = None extra_data: PluginExtraData | None = None led_spec: LedSpec | None = None + # Replace the plugin name text with the LoopIconGlyph racetrack icon. + loop_icon: bool = False # Per-symbol edit-math classification, supplementing the LV2 port's # Symbols absent here are ParamRole.GENERIC. diff --git a/pistomp/lcd320x240.py b/pistomp/lcd320x240.py index a581f5c62..233f69bb3 100644 --- a/pistomp/lcd320x240.py +++ b/pistomp/lcd320x240.py @@ -18,6 +18,7 @@ import functools import logging import os +import re import time import socket from collections.abc import Callable, Iterator @@ -56,6 +57,7 @@ Parameterdialog, ScrollingText, TextWidget, + LoopPluginTile, ) from uilib.glyphs.badge import BadgeGlyph from uilib.gridpanel import GridPanel, TILE_W, CHANNEL @@ -614,13 +616,10 @@ def _draw_plugins(self): def tile_factory(node, box, parent): plugin = plugins_by_id[node.id] display_name = plugin.display_name - label = display_name[: self.plugin_label_length].replace("_", "") - label = self.shorten_name(label, box.width) subtitle = plugin.subtitle or (f"{plugin.category}: {display_name}" if plugin.category else display_name) - tile = PluginTile( + common_kw = dict( plugin=plugin, box=box, - text=label, outline_radius=5, parent=parent, action=self.plugin_event, @@ -629,6 +628,14 @@ def tile_factory(node, box, parent): backdrop=self.background, foreground=self.foreground, ) + if plugin.customization.loop_icon: + m = re.search(r"\d+$", display_name) + loop_num = int(m.group()) if m else 0 + tile = LoopPluginTile(loop_num=loop_num, **common_kw) + else: + label = display_name[: self.plugin_label_length].replace("_", "") + label = self.shorten_name(label, box.width) + tile = PluginTile(text=label, **common_kw) tile.set_font(self.small_font) self.w_plugins.append(tile) return tile @@ -862,24 +869,24 @@ def footswitch_label(self, footswitch, slot_width=None): return self.shorten_name(name, width) def _footswitch_state(self, footswitch): - """(name, state_label, color) for a switch bound to a plugin that - publishes a state via its LedSpec, else (None, None, None). The name is + """(name, state_label, color, loop_icon) for a switch bound to a plugin that + publishes a state via its LedSpec, else (None, None, None, False). The name is the plugin's, not the bound port's — the port is a trigger ("Advance"), which says nothing about which loop this is.""" param = footswitch.parameter if param is None or self.current is None: - return None, None, None + return None, None, None, False plugin = self.current.pedalboard.find_plugin(param.instance_id) if plugin is None: - return None, None, None + return None, None, None, False spec = plugin.customization.led_spec if spec is None: - return None, None, None + return None, None, None, False label = state_label(spec, plugin.output_values) if label is None: - return None, None, None + return None, None, None, False color, _style = render_led_spec(spec, plugin.output_values) - return plugin.display_name, label, color + return plugin.display_name, label, color, plugin.customization.loop_icon def _progress_fn(self, footswitch, state_label: str | None): """Only the state view has a border to draw the loop position on.""" @@ -911,13 +918,14 @@ def draw_footswitches(self): fs.toggled = active fs.set_led(active) # a press never touches toggled for preset switches elif fs.parameter is not None: - name, state, state_color = self._footswitch_state(fs) + name, state, state_color, loop_icon = self._footswitch_state(fs) if state is not None: label = name color = state_color else: label = self.footswitch_label(fs, slot_w) color = accent_color_for(fs.category) + loop_icon = False fs.set_display_label(label) action = self.footswitch_event else: @@ -933,6 +941,7 @@ def draw_footswitches(self): taptempo=fs.taptempo, state_label=state, progress_fn=self._progress_fn(fs, state), + loop_icon=loop_icon if state is not None else False, parent=self.footswitch_panel, action=action, object=fs, @@ -955,13 +964,15 @@ def update_footswitch(self, footswitch): wfs.progress_fn = None elif footswitch.parameter is not None: # Binding may be new (e.g. MIDI learn) — reflect label + color. - name, state, state_color = self._footswitch_state(footswitch) + name, state, state_color, loop_icon = self._footswitch_state(footswitch) if state is not None: footswitch.set_display_label(name) wfs.color = state_color + wfs.loop_icon = loop_icon else: footswitch.set_display_label(self.footswitch_label(footswitch, slot_w)) wfs.color = accent_color_for(footswitch.category) + wfs.loop_icon = False wfs.state_label = state wfs.progress_fn = self._progress_fn(footswitch, state) wfs.action = self.footswitch_event diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py index 4e2d7e609..eb459f96c 100644 --- a/plugins/loopjefe/__init__.py +++ b/plugins/loopjefe/__init__.py @@ -42,7 +42,7 @@ # Short enough for a 320px/4 footswitch slot; the TTL scalePoints spell them # out in full ("Record Arm", "Overdub Close"). _STATE_LABELS: dict[int, str] = { - _STATE_EMPTY: "Empty", + _STATE_EMPTY: "——", 1: "Arm", 2: "Rec", 3: "Close", @@ -82,5 +82,6 @@ def _track_name(plugin: "Plugin") -> str | None: display_name="LoopJefe", display_name_fn=_track_name, led_spec=_LOOPJEFE_LED_SPEC, + loop_icon=True, ), ) diff --git a/tests/pedalboard_fixtures.py b/tests/pedalboard_fixtures.py index a6e33fc64..2fc0cdd00 100644 --- a/tests/pedalboard_fixtures.py +++ b/tests/pedalboard_fixtures.py @@ -59,6 +59,11 @@ def tile_active_color(self) -> tuple[int, int, int] | None: def tile_border(self): return None + @property + def customization(self): + from modalapi.plugin_customization import PluginCustomization + return PluginCustomization() + @property def panel_cls(self): return None diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png index a78383119dc899a1af2cb53621fcf81863fb33dd..ba26a24fc78e2443c8f4bcef1351a4c19e2ebdb0 100644 GIT binary patch literal 9993 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 literal 10062 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 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png index 3f22389e4a7abfe259baa28e1968467d457faa46..ac06648c5459b3409d8ba140ae004fb37b108e9f 100644 GIT binary patch literal 11135 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 JE@}Age*u#rMF9W+ diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png index d8b623686c3897a564554cf357121b90b2225e50..95754edb258873d6edbbbd9ef3c4d2cde6a12b17 100644 GIT binary patch literal 10092 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 D_1N2J literal 10047 zcmd5?^;cB!w;e!0Nof!P1*D~0LJOhBAin zWc<>q;K{_of$k6UGHv(5=Xuh}1w-q^Em;&_)i^Z{9$m-Q!n}=v`7!Z~SZU@g^V<9C z@2<_AiqR7bIGQa_$GHgzxGYaUkwJK`sBn+C>yfTqUa(l*5r*y5e+`lzyYBVvLDj^~ z0V}nyseJCrQw7)?7_}*%nGj zO^?UhM<|~7dtt%XplZ?Kt-QRy8e5bCv*dCA=xA40uu4{>0`rZK<+Zi7)m7xvr@zL= za#&frhKGlzrfO1B5J9|tGO1Uvv4;i+?L=>O3!e__*b;I`kQ=68g@%UG)6*L}sM6)$ zzm;87WNTp&p_mc)ePDF7wY9aS1ud5r_xUppHueim&5|+e#AE>-9UWWS?aWLT)Y_w~ z0WI_8<-)eMw(rADO{jG$RBh|dg=H?&c*$VP7;bKE{Be_0Q|F8Ak+9kYy?ur{dwjQz zrKJO0E-tK*?Lb;|lw?p!njHI5|11NrQS<9NpTN{+^t;N+oZvt{xrj zy!1HQe{dDHen^!9N8fHNE-E@WJWTX@ftW#x03W~UqW0}Bi~TQOTw>Hqil!zC>Q!%9 z#uxdxqHgsPPKWM*bU z_>nI(3=Ivnv}nTG22Q3IgmM!zf0=i8cQ=a(2?^mDB}uxqqKa|K#CM#CR12#3;<*%YHq|HeF*B(|kw9G6 z;~HuL9AR89fqdyV67)eW{KCHv=gq9GyNvmVs#n57L#M0X3h28+qPuKQ7nrW0A(%li zF;rzEaLy66sKeT}zkmO(^B^dX{rr6DDnkPU8TH-Z>xY|Tkr5H8p2o9y1og8D%pW^C z6lrPWcNW?>b^Sv^LOk5vkz5)kCK-QS*gHN(MvfF2Bu7QPkDjh^JJhdw{Yovu$ILKA z9>VvvQ!{x+VU?IWMn_*?%4V`MfQK0ZJYMo6q*Y+erl{H8FWVm8>FU@%-M}fQgC8-Me>51JX-MN`Cw}Ss%({@H#6l zD%y4o4U@W&iFwv@Z~MAPUhmzOBpp}z<~v{NFoWOP{Nm;3AFs53X=Rlc`19w|-;5_Q zq4`aD6n(K6t3pqnbauw@s3uE!)Q3BX(}hU&(IZ7~853h}cf&M@xQ=jXnF*MBLmzF*H=>xY9E_Yv}0ch>wr&<#ibq74_{Kzo2#l z1vNpa0y75}S99AojYfNWd$WNEcML5V*)ugY?_TyT=RdOz6r36#WEllJay)S~JPTtb z-40rUsNiIgT&5*sE4|6Y*RI_zbof0fHUy8=($f5gl9JMDq_D6V^s{^-37Mq`3Rh*Q~4+$g_ZZiYh7^iIl3Uy=n4c!-Ing z8S2$0nP~JQYwOM5Q&j>20vqe=S1^5K87*GDy355S@%Haxf|w&Y>WildAaB1 z`C*Y>m4<-<&-JSS3zk+^JO;JZmP5HxZqEm1H_eMxj71CUpL0Y5M2Ot8ot6+9>1J5{ zLnNFtL9wcC^#RkZ0h{s**6;-~YwE1Hx1wenCS+(=5;PieeTUpR%;9Y`-D`5LTY z7=72ZKXXkAd7rqn91b?Mtc|;+#6(4PzdI~t!2j8p$;Txo(%mq{qtNu-;!>;nY3e>%( z&OC{VOvRKJ5_oU2KC;4&MTYGB?c^b&DuAG<(YQAG5jGiWX5Jgo~h}iER9UVQ$(0oKRQEAU)KNS^4KCf-yvH!yBbm(Yv zb<1rwh>C~QWn^V3Px_orzuVz+tC*3fO5kgo)QRzNh?Bo6$KGx8SFa?q3m(bKFL>>~ zsRoRQsN4gvIGR~#3u6#4B{(>Qwj9u)k|6pZLBe$}`?Fh&0yE&4iPsZ@H*;u@(}SrR zw~FuIwb;l}{#9STyrXVye{3~%*&ECE@X;4?KkKl-KvyC&dCl?h@zRUWBHC#Qnb8Tn zO018XMihpIhWO(MlACeT8;!64jK;=jF5}ycNh|WZMn(?XBUwjp{W=1MqJiEmK<&w6 zFD)(MZJ+=?Qy=;des`p`OCj7RaYXnxJ*5s)HL_Elx2LTwN#n$$OM@EsT%B+lX3Mg; z!%8sl_?KN7EHj_Jj>VVq;05)b*lPS=y2O)-%_966lg>{$$CE;%c*L(><<^}dx02{| zt*yD@lwLbJPCy%S%trTgbup`0E;Z?YloM0t6f=rd3DiHM9vKHab3@s}d#d>tar&@%CWO zpMhTDja}tobaeDkfB!qxlcS^TOL}CLm@=!_gD||WGSVX*JYzOfKhT%mOAc#J;yGTd zs!7c<90Zk>Z+1}gi}$vx1)9yttjMABxUBZw7LE^zKDf}I%!|{ffPOj#X#oVG+K@AY(@I=aYE$h&*LjcBg~{0ErAs4gANv-1_XPq;i!8D)TNEO zsQ{0iPk>ZdAa>)T+&I{pD5|^oQBzYB5O58sjI^iw^XG%|3?5GV>q7%sstZ<$QXZd? z&tGqj@_AjjKYRA<-n}Zls@LpZ>%7S32MMf7vEDTZpgzc|*9C!!JkI@jjQ}mcVEj~V zW@t zIoaE5yER@0XfGc||00WCp(G*GUZw9)m3_$l%D0jdrrLUD?K)3MN>f-HNY7s%fE{$@ zQm@phQehH?bjI^?&vREHk&7|2WJ2K@4#x1L)t_?RU5b<_r8=yrsH{@bN!Xv@{r)rE!J&{&}UOG``p`ue=iHw+%C7U7xoCeaC) zcHuI(het#-n3glEo&j$$&#&O#MAW3Ik@aQ4meBtG8{(M=?*^*bj~_o$8Mu># z-sFAG({U5=Jd3*w9L%q247q_Fhy0>7{!bqp-lgqe+6qZ z)l#~C2o4SgWunDJ8LdbWAgjw=k(nu{6H>50z^23>A{P`ALP$gejmXBzij$2^K~m?F#gOT%(Mu%u)cPBSqv@iFs9B^Ch@5f?{CS^?AG z`?u6qJ6jEd8<_zspo~R0I0BVe@ULJ(QyVKWYinr{5)t8adYT?LJ3CunUoT+Rb4yQc zi1kxkoR+zHc0oY_bU3K~2qXK~uag8V)Pbsj#_|(!HsqzgxVVU9QNCflO~};9rsUw{ zw8xt4{xLb(Yw>49f4{nQW^%HDx_Uo+_@%Gei+Q~QF)=aW@8{QVUJASgmzaKb7Yh?p zS6BBR21UOLkX6^xdc66d2gR9jmsEG%ry{~dq= zf?r3J=lApHPc}BTY>-RPc$$x)yTUhspgwr;;7t*J_#%nxCksJsMMXtVPpN4FS^@j{ z=J4?Fv$HeE#Ds*KscNDxTZ8}c3_vNMYK^{IV^M1WgTvlr52I#@e>`5mBZZ8vwL?=*`U%Z(74Q`0p!%w$QLi1&rkN(*Vj{0ZdE6{>F7*A7lRF%7#l-vPyGC8K3-}8 zvTi-!%EUzI)wp1s(m^Lroi5-ZU;#hBL+r_YXBU?&^&Hqma~vS=;r{+zP_FFksp>hu zfB%M~^7sU)1u+l1DJGNf-MIbZQfCabyR)AWOc0aNk`2J<@v*VTH#gt48~dTrM&~2` zXfzzy%3PB_=wl^C#V_hP5ZKa+ijiB20R4d878Vv>rxPP$Eh0)zRh_|gJ!9&A2{ga31hcnWbXO8G;F$#R%0NKyHM(^tC>X2NuZil~{ z{E4HP9%QHkPp;JEK3F)}922Mp3T$8?m2B|*IUEB2d6vkw5ZbJsACRV)KC>6+ zr^wT;z(73uu&n4F+2~?qerPBWipi*YPviOX((>}xPEOaW>fSSlHJ9AHc~e(MCul~D z?1`*;X|zCUTNp`=N1+@GBdVb?DZhhq5DBGTUl7N166o^s=yr68BlWt_-AZfoRP-_ zSVGX3c}Rm=BB+yFrUhPW(wkr-2cq3Ou%Y59KJ!NKz~yuJBhu zq7x!2K~J1afQQ%kgh$xFZDEe;GB}t3605II&Dvo&U+Zvt2J|SHBW-Q0A|g~XDqyJC z+ZUVO?K0ulf^w>*gA;%H)W>D(U~laie_U|LJrfj)^u~?P@$pZpFYmIkT_qscJf#^O z8WIu|j8bCJ)YPP+q|E(rMVOI93T%twftM(h3}ZwyijVIrxLL2)GEsV)nh8e_3$-*w z*VR5y#B_G|^sq<;Jk5EI$`TG=jCk`2D5iv@k8Bct2#-K`x3 z02vh(hoN-$zLMPjY5YxQkYJtgWk4Vgg_8 zn_(Or9=3&AJb2LH?YwbLXOqt=f`^t#c$}dgz^b$V;~zVG`GSCqI|hoq%aHd^lRQ@q z0Zvn6%@=VT8j;3Fg(n1B3*DC9HiUJi|ou}8K;^{XU`XE26HR`fl6 z`V^YfJN@bD>5|j_8D0rl;2ZE*U|t>FBqJkZiOf;KMIlWxq4`kCi>akTFJUGiixWlkrF>|a2O$0VQ(=fH?10>m zBid5@x{wKr6!$} zaY}MGa|m2^mz9~A(p+XdVpOu+TwUQPa^50OoR0n&0j&TTXvCuo6mYpWxrjG3)z3G6 z-@j>Yad8oJQP*doysE1B&LvY569`;Ri4Cv`(1+jxWh%v?tgR=33k(hlM(ySKd6Zr! zCl~ZM*#o4>AMR*ulwl@?W~zFwdgE66iTfwpeb20jj~^XY`%=fpwdup!jQOV~Cqd7Y z+f3!2Q2>023>WCM&DnjJky>0@^4R+&|0IT6(&hKf1A0+0u{dOi|AL(;@WLmGih)5v zoIE_2XS=-sGaeyK1}|QeIV^R6g#A5JHv_QMZ3;P=B<+<%ow=gI!ogu#kDm{MXYM|I zlNq@uwFF+I5^axdDh7}h-jlsa88U+gT2hMrFX4;7>^?mtC>8NKdv@J}hJhN9Lc>=7 zKa@r;NLu=FNI<~b{UOcsvomrjbq4!pl)Z(;TCen_I~=oK8@Bff3i<$WD{!`%YW5i4 zvym$r8yj0%u7e(QrlUgm87L_!Sz6{|kn1txp+A29e1nWEZQ}!iw+nOu6%9=Qfr_#5 zB1GDW4jETE*!thN5J9(hYA)DU=E=t-(Vlss95ou>j%OmcX z%e?_r1((gOEhv-T#CvTEJK$d@6Xsx0Czej8c!}!PxC(J`DZwK58A7}v4NA|oYbjOkQO zk^sN%xQSTW?)xP0Mli~ZBX*!%9yn?;#HyL76)6)4<%W*{4 z?zGZ+$g%vR>(NM@u>dN>Lyr1qSt`+;OW;4lJ%HCLu{h2(qIJ17xnpc6E1#b8A~8O7 zE`h7c$HxcM6_5d%l*9Lgra42pV@_{Of_gRlKj1DdIvkh3nurRblu|^C8-2wDO z)WHtatgNhPUIe0m!qEC>Ky$O~d>4osfDlQ~GiqA&(?s6bKY#v!SR?hlt6h3^1`~0= zGa5ONsoZ20tEa0AZFIu6u6xc79t8}VL1Nz#nV9J4?pQuwm8>vYk>5u<3u^d;^?zz{ zFXn@IqGooWFiCVS38-;6>%oe(wXxB7LoDI)TX+6cOmMp$_Wo9aT1heksv<&1m+6JchV{0%WKh}4t!Xz4b$5QXQ~5}*MSC} z9lD}3cw4Y8=qWHOLX64rmRhI`&W;wsc6WEd!7tiaMetS|np6Bwu^|vChbg4^-Xuh= zxHyLC?})_^)D4F2h6p)cY6fcW2>gE^Ul};u@d0Oz*0y>Fcodu!z!U)eKi^jC+k4rQ zl8c+(PnCV8ij%THJ|2$UrX0SkvIM7?_sz*PlmGo&p#J9@(fA%79xw}lR^#$}GT^$R zf`a!iIgGwTPP=4iuPia0#5YA##Hn?Cd>UvY7O++p7Y&uYgn}dxelM?t5C>1xdDZE1 z8K{`{s23Yp=akka`5f#0;o@1a$~@ zWlG9@?ikKs1;6W!zuy-ZdqJ8}%x-R4qj`ZYMRhJ!z1~s}4K65n_4H{3lFM_eY{V}~ z5J6D?freppdinw^?46yRfUvMSkfnxcgx(Ks&@5oxyLZpb)D(OWF747)ctYP00fe9B zm0)5C7%b%H=R*>*Ff-p*Ek*>V@A%YIS6f@9Z;UVdFalH0ArHY6oXUvVOE|~y(Qlw=q2yM z{E3iOcs7#xqwqG<9&b%i^H#`3j97P`k)Tm?`v4P6En8@+^19 zudJ?$g5U(5nU0I(qKxT$(bFB<)1v}I98@tuCZc4IrAC{r9;_)qm4n^glCNJ^H#gzX zYCO-y`CjE>DaG>MmXeYZ5*li4H9gr|1JZtzXa!aVs(HRuTvg=;=Cgu)l9wxm%`)(P zc5+1-8O)#-8CP$)G#Xmk-D7tOqa$~zpcWYT;{0J&8pzrsDnksImjWq*QL1%+M+XNZ zBWkkJ9%jx|SFfyJsh5Q#!NxZ7x+g9U)j&;4yRx)&TQZGx4UCGbsn8tKaleWq17yE2 z5|on{70~?(-e<3QIMonLBVhgNl@*L5^s7e)2aV0lL;!VNTn=MnW1(oX$q;1MuiHU? zfjhyJ349+oz!817yVBAIGy2Ty4_(`h!9);@={#QulMbh^pU*#efTNRZYc_!BpLk{@ zK#qZdfhs;~4j==bM-kKlxCVwLd4Mc@Zi!QWc5%85M&xf8I|54wx!v6@Bp|T6wrv}L^^Qz#i7lQ81kTC7cL89Ash$If9moqVgL=*&;&*aHQie-5 zc5J&b_z9p5XB|Pu-MDchR*6MJOu`VMWMXp1zGRd+vK^MMSQFyod#t2%6MRd+4A61? zw8%ulM3rqv_C%F7OONo>*>z=Ce{F9P8LZk({@4nyAIdz5Dh6ei(Rq0rpq@d^hD%wP zn_tI6Lk@Smz2U~gL5{b-PSs}Ee{T@8YhfoLA%TmF>yH?;dq_r*Zk=sdIQ7A_BB7O9 z)*sszAVhh4-;ntbSWeEohyFyxW7g?rKz9KBl~{HU4qm&snDJ6Oi6=G~Rzl*qxq)xE zzHa&Ogf3>|zxNgt6a@K$LVXDe!bi)1;iICY1QZ_p0yy33KaKbxME3N7nS^}!u)4mU zTUaRPD#yA8VE`xguFUFegD=kLkHC}v1 zjqicN>dwXWd54tmVR{syhYR0dQ@9ZOd~J*tA!NSttcabS?X)f|WNPXIk4%WWQyozv z&wb5KwL^tGkIij9>f8SW2Rh#9E*>79_BZ2pKoTfXe+#Pmdh<&iWqum1_=B_EGWlyp zY*jb9pFB*cjemte>}4XnA_6XEdL_MLpM~R+)hvccefG(8{lf4m#lrtYZzD`!;nLXD z^y19X*0og8b^u@Qi;>0M9i`yFG0DrGrL>u6t{ryy#ELxITBWydoI4WTsK9?gqHp%e z>%;h!)fOzTv7hT#_ZplQQ6U^+=55_c%4@`E79X%0Ad>%l(U&jN0LgT96F^KWF#mvI zCIse0=Sk}AIdGIDU?ls#@9$D)@t?PE--1VA0OK|=ib}qH`#^%Mut|gw9nd8BZL-^X zLv~KHN^PYeYLBY3$ji;0w-W`|wmA+80!T8< z_0j$Kel<1oTk?{ZZkJ0{em>oV_ZjpbSRA@>)Kj9r)Yz|fj`UqFGR-+!hYHuk1QI{O z#Kh#*tyDuI&5e!s;I#z*_wVzyN;1EEVU(0i>|HrMJ_h?kUQQ0mj#Q-I;9(=oeZkFO z(?l&TFV`N7TY-HDGumsytP&EsERis?aybgaxSw_|q;6#!U2x}>ofX|B-BwM=XK`1{ zx&xCmTStq`vp_Lml?sB+AOowOozI z#xZ<@|254-wM;-}wZkiU?JHH|%c~>td`gR!1(jbKW00f9Wz$03gYN_V-j5b}JH!=1 z3oR-FXaE;;e{b(>t73*A$AZ4E3e#Hd!3ELk_CO!QMnnlkhx>be`it+`)fKemTRcn* zRY``CZ7D$k3dbB}XV`mTY{v5PKNRo!0c$qqTTz-owZI;)Hr`LO1F!nQPZ06g!8F?2cIK`ry|-eFoh>owo-ero%H@Y+a$&S3*CL-0x3)x9dQ+iIr9 n#)9B2f(a+Nxki60@#ONMgGq67-ImxtQu_Fjifo~b;k*9=^f-W; diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png index ff301258406c0ebf02b154a3eab402c87f5605e1..82882b7a4675c567aca475147b9460ac25a893e3 100644 GIT binary patch literal 9542 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 l#hVDdG{K%}LSltvIK>29Twh9M=s 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 diff --git a/tests/v3/test_footswitch_state_view.py b/tests/v3/test_footswitch_state_view.py index 0e578919a..717f1e0c4 100644 --- a/tests/v3/test_footswitch_state_view.py +++ b/tests/v3/test_footswitch_state_view.py @@ -71,8 +71,8 @@ def test_state_view_falls_back_when_plugin_has_no_led_spec(v3_system: SystemFixt plugin = Plugin("/Reverb", {Symbol("gain"): param}, {}, "Reverb") handler.current.pedalboard.plugins = [plugin] - name, state, color = handler.lcd._footswitch_state(v3_system.hw.footswitches[0]) - assert (name, state, color) == (None, None, None) + name, state, color, loop_icon = handler.lcd._footswitch_state(v3_system.hw.footswitches[0]) + assert (name, state, color, loop_icon) == (None, None, None, False) def test_state_view_repaints_on_output_set(v3_system: SystemFixture, make_parameter, snapshot): diff --git a/uilib/__init__.py b/uilib/__init__.py index 98bdb7cb3..042dcad4c 100644 --- a/uilib/__init__.py +++ b/uilib/__init__.py @@ -37,6 +37,7 @@ "PanelStack", "Parameterdialog", "PluginTile", + "LoopPluginTile", "RoundedPanel", "ScrollingText", "ShroudedPanel", @@ -80,6 +81,6 @@ ) from uilib.panel import LcdBase, Panel, PanelDecorator, PanelStack, RoundedPanel, ShroudedPanel from uilib.parameterdialog import Parameterdialog -from uilib.text import Button, LetterSelector, PluginTile, ScrollingText, TextEditor, TextWidget +from uilib.text import Button, LetterSelector, LoopPluginTile, PluginTile, ScrollingText, TextEditor, TextWidget from uilib.widget import Widget diff --git a/uilib/footswitch.py b/uilib/footswitch.py index 2c228c170..bcb0adf1e 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -26,7 +26,7 @@ from common.loop_progress import LoopFill, LoopProgress from uilib.box import Box from uilib.config import Color, Config -from uilib.glyphs import CircleGlyph, RingGlyph +from uilib.glyphs import CircleGlyph, LoopIconGlyph, RingGlyph from uilib.glyphs.perimeter_progress import PerimeterProgressGlyph from uilib.glyphs.tint import tint_mask from uilib.misc import InputEvent, get_text_size @@ -114,6 +114,7 @@ class FootswitchWidget(Widget): taptempo: TapTempoProtocol | None state_label: str | None progress_fn: Callable[[], LoopProgress | None] | None + loop_icon: bool _pulse_on: bool _progress: LoopProgress | None @@ -127,6 +128,7 @@ def __init__( taptempo: TapTempoProtocol | None = None, state_label: str | None = None, progress_fn: Callable[[], LoopProgress | None] | None = None, + loop_icon: bool = False, **kwargs, ): self._init_attrs(Widget.INH_ATTRS, kwargs) @@ -140,6 +142,7 @@ def __init__( self.taptempo = taptempo self.state_label = state_label self.progress_fn = progress_fn + self.loop_icon = loop_icon self._pulse_on = True self._progress = None self._progress_key: tuple[int, int, int, int] | None = None @@ -215,15 +218,37 @@ def _draw_state(self, ctx: PaintContext, w: int) -> None: self._draw_progress(ctx, w, ctx.height) font = self._slot_font() - name = self._fit(self.label or "", w - 2, font) - nw, _ = get_text_size(name, font) - ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=font) + if self.loop_icon: + self._draw_loop_name(ctx, w, font) + else: + name = self._fit(self.label or "", w - 2, font) + nw, _ = get_text_size(name, font) + ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=font) state = self._fit(self.state_label or "", w - 2, font) sw, _ = get_text_size(state, font) fill = self.color if self.color is not None else self.BOUND_OFF_LABEL ctx.draw_text(((w - sw) // 2, self._STATE_Y_STATE), state, fill=fill, font=font) + def _draw_loop_name(self, ctx: PaintContext, w: int, font: "pygame._freetype.Font") -> None: + """Render the racetrack glyph + track number instead of 'Loop N' text.""" + import re + label = self.label or "" + m = re.search(r"\d+$", label) + num_str = m.group() if m else "" + glyph = LoopIconGlyph() # 48×14 default; module-level cache makes this free + gap = 4 + nw, _ = get_text_size(num_str, font) if num_str else (0, 0) + total_w = glyph.width + (gap + nw if num_str else 0) + gx = (w - total_w) // 2 + # Vertically centre the 14px glyph in the 15px name row (y=2..16). + gy = self._STATE_Y_NAME + (15 - glyph.height) // 2 + 3 + ox, oy = ctx._f().topleft + ctx.surface.blit(tint_mask(glyph.render(), self.BOUND_OFF_LABEL), (gx + ox, gy + oy)) + if num_str: + ctx.draw_text((gx + glyph.width + gap, self._STATE_Y_NAME), num_str, + fill=self.BOUND_OFF_LABEL, font=font) + def _draw_dot_and_label(self, ctx: PaintContext, w: int, is_on: bool) -> None: """Small dot on top, label centered below.""" cx = w // 2 diff --git a/uilib/glyphs/__init__.py b/uilib/glyphs/__init__.py index 1eee0d841..09cad8c93 100644 --- a/uilib/glyphs/__init__.py +++ b/uilib/glyphs/__init__.py @@ -30,6 +30,7 @@ from uilib.glyphs.expression_pedal import ExpressionPedalGlyph from uilib.glyphs.keycap_corner import KeycapCornerGlyph from uilib.glyphs.knob import KnobGlyph +from uilib.glyphs.loop_icon import LoopIconGlyph from uilib.glyphs.outline import render_rounded_fill, render_rounded_outline from uilib.glyphs.pill import PillGlyph from uilib.glyphs.rounded_rect import RoundedRectGlyph, render_rounded_mask @@ -50,7 +51,7 @@ "PillGlyph", "RectBorder", "RingGlyph", - "RoundedRectGlyph", + "LoopIconGlyph", "SignalBarsGlyph", "SpinnerGlyph", "render_rounded_fill", diff --git a/uilib/glyphs/loop_icon.py b/uilib/glyphs/loop_icon.py new file mode 100644 index 000000000..f8cb98c5f --- /dev/null +++ b/uilib/glyphs/loop_icon.py @@ -0,0 +1,122 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# This file is part of pi-stomp. +# +# pi-stomp is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# pi-stomp is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with pi-stomp. If not, see . + +"""Loop-track icon: a horizontal pill/racetrack outline with two staggered arrowheads. + +The top arrowhead (→) sits right-of-centre and the bottom (←) sits left-of-centre, +suggesting two runners chasing each other clockwise around the oval. + +Rendering pipeline: PIL 4× supersampling + LANCZOS downscale for analytic-quality +AA at small sizes. Returns a white SRCALPHA pygame surface (alpha = coverage); +callers tint it with tint_mask(). +""" + +from __future__ import annotations + +from functools import lru_cache + +import numpy as np +import pygame +from PIL import Image, ImageDraw + + +@lru_cache(maxsize=8) +def _render(width: int, height: int) -> pygame.Surface: + s = 8 # supersampling factor + W, H = width * s, height * s + + # Stroke width ≈ 2/14 of final height, rounded to even at scale. + sw = max(s * 2, round(height * s / 7) // 2 * 2) + + # Inset so the outer stroke edge doesn't clip at the canvas boundary. + pad = sw // 2 + s + + big = Image.new("L", (W, H), 0) + bd = ImageDraw.Draw(big) + + # Stadium (pill) outline — full-semicircle caps. + inner_h = H - 2 * pad + bd.rounded_rectangle( + (pad, pad, W - pad, H - pad), + radius=inner_h // 2, + outline=255, + width=sw, + ) + + mid_x = W // 2 + top_y = pad + sw // 2 # centreline of top stroke + bot_y = H - pad - sw // 2 # centreline of bottom stroke + al = (sw * 3) // 4 # arrow half-length (base → tip) + ab = round(sw * 1.5) # arrow half-base — wider than track for visibility + gap = s * 3 # gap between arrowhead tip and resuming track + offset = W // 8 # stagger: top arrow right, bottom arrow left + + top_cx = mid_x + offset + bot_cx = mid_x - offset + + # Top → : base at (top_cx - al), tip at (top_cx + al). + # Erase from the base rightward; base side stays flush with incoming track. + bd.rectangle((top_cx - al, 0, top_cx + al + gap, pad + sw + s), fill=0) + bd.polygon( + [(top_cx - al, top_y - ab), (top_cx - al, top_y + ab), (top_cx + al, top_y)], + fill=255, + ) + + # Bottom ← : base at (bot_cx + al), tip at (bot_cx - al). + # Erase from the base leftward; base side stays flush with incoming track. + bd.rectangle((bot_cx - al - gap, H - pad - sw - s, bot_cx + al, H), fill=0) + bd.polygon( + [(bot_cx + al, bot_y - ab), (bot_cx + al, bot_y + ab), (bot_cx - al, bot_y)], + fill=255, + ) + + # Downscale to target size with LANCZOS for sub-pixel sharpness. + mask = big.resize((width, height), Image.Resampling.LANCZOS) + + # White SRCALPHA surface — tint_mask() handles colourisation at blit time. + mask_arr = np.frombuffer(mask.tobytes(), dtype=np.uint8).reshape((height, width)).T + surf = pygame.Surface((width, height), pygame.SRCALPHA) + pix = pygame.surfarray.pixels3d(surf) + alp = pygame.surfarray.pixels_alpha(surf) + pix[:, :, :] = 255 + alp[:, :] = mask_arr + del pix, alp + return surf + + +class LoopIconGlyph: + """Racetrack loop icon: a wider-than-tall pill outline with two chase arrows. + + Renders as a white alpha mask; use tint_mask() to colourise before blitting. + Geometry is cached at module level on (width, height) — multiple widget + instances sharing the same size pay only one render. + """ + + def __init__(self, width: int = 42, height: int = 14) -> None: + self._width = width + self._height = height + + @property + def width(self) -> int: + return self._width + + @property + def height(self) -> int: + return self._height + + def render(self) -> pygame.Surface: + return _render(self._width, self._height) diff --git a/uilib/text.py b/uilib/text.py index 8e8d03178..6232d453e 100644 --- a/uilib/text.py +++ b/uilib/text.py @@ -38,7 +38,7 @@ from common.color import ColorRGB, RectBorder, tile_color_for from uilib.paint import ColorLike -from uilib.glyphs import RoundedRectGlyph +from uilib.glyphs import LoopIconGlyph, RoundedRectGlyph from uilib.glyphs.badge import BadgeGlyph from uilib.radius import Radius @@ -547,6 +547,33 @@ def _draw_outline(self, ctx): return +class LoopPluginTile(PluginTile): + """Plugin tile for loopjefe tracks: renders the racetrack glyph + track number + centred in the tile instead of the 'Loop N' text label.""" + + def __init__(self, *, loop_num: int, **kwargs) -> None: + kwargs.setdefault("text", "") + super().__init__(**kwargs) + self._loop_num = loop_num + self._loop_glyph = LoopIconGlyph() + + @override + def _draw(self, ctx) -> None: + from uilib.glyphs.tint import tint_mask + from uilib.misc import get_text_size + num_str = str(self._loop_num) + nw, nh = get_text_size(num_str, self.font) + g = self._loop_glyph + gap = 4 + total_w = g.width + gap + nw + gx = (ctx.width - total_w) // 2 + gy = (ctx.height - g.height) // 2 + ny = (ctx.height - nh) // 2 + ox, oy = ctx._f().topleft + ctx.surface.blit(tint_mask(g.render(), self.fgnd_color), (gx + ox, gy + oy)) + ctx.draw_text((gx + g.width + gap, ny), num_str, fill=self.fgnd_color, font=self.font) + + class ScrollingText(TextWidget): """TextWidget with horizontal ping-pong scrolling for overflow text.""" From 3c2bed09334808626970f767c2671cad68e38af5 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Wed, 19 Aug 2026 00:56:06 -0400 Subject: [PATCH 14/18] Better empty state --- plugins/loopjefe/__init__.py | 2 +- .../recording-chase.png | Bin 9993 -> 9988 bytes .../overdub-from-output-set.png | Bin 10092 -> 10077 bytes .../empty.png | Bin 9542 -> 9537 bytes 4 files changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/loopjefe/__init__.py b/plugins/loopjefe/__init__.py index eb459f96c..68265c2f8 100644 --- a/plugins/loopjefe/__init__.py +++ b/plugins/loopjefe/__init__.py @@ -42,7 +42,7 @@ # Short enough for a 320px/4 footswitch slot; the TTL scalePoints spell them # out in full ("Record Arm", "Overdub Close"). _STATE_LABELS: dict[int, str] = { - _STATE_EMPTY: "——", + _STATE_EMPTY: "\u00b7", 1: "Arm", 2: "Rec", 3: "Close", diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png index ba26a24fc78e2443c8f4bcef1351a4c19e2ebdb0..9a80f0692120a6064365a0761b8dec4c49abfc1b 100644 GIT binary patch literal 9988 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 Mw6avOgz>BY1z(h)4gdfE literal 9993 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 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png b/tests/snapshots/v3/test_footswitch_state_view/test_state_view_repaints_on_output_set/overdub-from-output-set.png index 95754edb258873d6edbbbd9ef3c4d2cde6a12b17..bdeef22fc123495b6bb11b847b968f2d07226004 100644 GIT binary patch literal 10077 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 D_1N2J diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/empty.png index 82882b7a4675c567aca475147b9460ac25a893e3..44359998173b751f45f2c8e28940e0824e2c6e2b 100644 GIT binary patch literal 9537 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 TvdW7eOCc0x)nrN@nuh)tzIUuE literal 9542 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 de_Dm<1u5q3pXSqIbmhO# Date: Wed, 19 Aug 2026 23:21:54 -0400 Subject: [PATCH 15/18] Easier to follow footswitches --- modalapi/led_render.py | 22 ++-- modalapi/modhandler.py | 45 +++---- pistomp/beatsync.py | 9 +- .../recording-chase.png | Bin 9988 -> 9846 bytes .../play-bar3-beat3.png | Bin 11135 -> 10875 bytes .../transport-beat-0-on.png | Bin 0 -> 9197 bytes .../transport-beat-1-on.png | Bin 0 -> 9203 bytes .../transport-beat-2-on.png | Bin 0 -> 9202 bytes .../transport-beat-3-off-50000us.png | Bin 0 -> 9165 bytes .../transport-beat-3-on.png | Bin 0 -> 9173 bytes .../overdub-from-output-set.png | Bin 10077 -> 9938 bytes .../empty.png | Bin 9537 -> 9537 bytes .../recording-and-playback.png | Bin 10317 -> 10103 bytes tests/test_beatsync.py | 15 ++- tests/test_loopjefe_behavior.py | 50 ++++---- tests/v3/test_footswitch_led_driver.py | 33 +++-- tests/v3/test_footswitch_state_view.py | 77 +++++++++--- tests/v3/test_taptempo_led.py | 43 +++---- uilib/footswitch.py | 41 +++--- uilib/glyphs/perimeter_progress.py | 117 ++++++++---------- uilib/paint.py | 41 +++--- 21 files changed, 250 insertions(+), 243 deletions(-) create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-0-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-1-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-2-on.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-off-50000us.png create mode 100644 tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png diff --git a/modalapi/led_render.py b/modalapi/led_render.py index 0c250e948..63ae3f4a8 100644 --- a/modalapi/led_render.py +++ b/modalapi/led_render.py @@ -16,9 +16,8 @@ """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. +footswitch, beat, or plugin-instance coupling — the handler applies the binary +metronome state uniformly to the physical LED and loop perimeter. """ from __future__ import annotations @@ -32,9 +31,6 @@ from modalapi.plugin_customization import LedSpec -_BEAT_DECAY = 0.7 - - class LedDisplayStyle(Enum): SOLID = auto() METRONOME = auto() @@ -59,13 +55,9 @@ def render_led_spec( return base, style -def metronome_brightness(beat_phase: float, is_bar_start: bool) -> float: - """The per-beat brightness envelope: full on the downbeat, decaying across - the beat. Shared by the physical LED and the LCD's progress border so a - slot and its switch never pulse out of step.""" - if is_bar_start: - return 1.0 - return 1.0 - beat_phase * _BEAT_DECAY +def metronome_brightness(is_flashing: bool) -> float: + """Binary brightness shared by physical LEDs and the loop perimeter.""" + return 1.0 if is_flashing else 0.0 def state_label(spec: LedSpec, output_values: dict[str, float]) -> str | None: @@ -85,8 +77,8 @@ def loop_progress( """Where the plugin is through its loop, or None if it has no loop to be through. `bar_phase` interpolates within the current bar — the plugin only publishes a bar index, and a per-sample position port would be a monitored - output changing every process cycle. `beat_brightness` is the envelope from - `metronome_brightness`, applied only to the states the spec says pulse.""" + output changing every process cycle. `beat_brightness` is 1.0 during the + fixed metronome window and 0.0 otherwise, applied only to pulsing states.""" if spec.bars_symbol is None or spec.downbeat_symbol is None: return None color, style = render_led_spec(spec, output_values) diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 69b3cb3f1..4fe60981d 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -116,7 +116,7 @@ ) from pistomp.footswitch import Footswitch from pistomp.footswitch_chords import FootswitchChords -from pistomp.beatsync import BeatGrid, TickState +from pistomp.beatsync import FLASH_US, BeatGrid, TickState from pistomp.input.event import ( AnalogEvent, ControllerEvent, @@ -305,9 +305,7 @@ def _rest_get_with_retry(self, url: str) -> Response | None: resp = self._rest_get(url) if resp is not None and resp.status_code == 200: return resp - logging.info( - "mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay - ) + logging.info("mod-ui not ready, retrying (%d/%d) in %ss...", attempt, len(STARTUP_REST_BACKOFF_S), delay) time.sleep(delay) return self._rest_get(url) @@ -471,9 +469,7 @@ def _handle_switch(self, event: SwitchEvent) -> bool: # ControllerManager._bind_encoder_longpress. if event.kind == SwitchEventKind.LONGPRESS and controller.midi_CC is not None: key = f"{controller.midi_channel}:{controller.midi_CC}" - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.ANALOG, id=key), EventKind.LONGPRESS) if winner is not None: self._fire_row(winner, event) return True @@ -488,9 +484,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f Handler._handle_footswitch imperative if-chain.""" if kind == SwitchEventKind.LONGPRESS: key = fs.dispatch_key - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.LONGPRESS) if winner is not None: self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp)) return True @@ -500,9 +494,7 @@ def _handle_footswitch(self, fs: Footswitch, kind: SwitchEventKind, timestamp: f # Short press key = fs.dispatch_key - winner = self.effective_table.resolve( - ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS - ) + winner = self.effective_table.resolve(ControlRef(cls=ControlClass.FOOTSWITCH, id=key), EventKind.PRESS) if winner is not None: self._fire_row(winner, SwitchEvent(controller=fs, kind=kind, timestamp=timestamp)) return True @@ -651,9 +643,7 @@ def _drive_footswitch_leds(self, beat: TickState | None = None) -> None: 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]: + def _render_taptempo(self, fs: Footswitch, beat: TickState) -> tuple[tuple[int, int, int] | None, LedDisplayStyle]: """Built-in renderer for the taptempo footswitch: while tap tempo mode is enabled, flashes from whichever beat source is active — transport-anchored beat grid, or taptempo.anchor + bpm blink when unanchored — else falls @@ -665,21 +655,22 @@ def _render_taptempo( return (_METRONOME_DOWNBEAT_RGB if beat.is_bar_start else _METRONOME_BEAT_RGB), LedDisplayStyle.SOLID return None, LedDisplayStyle.SOLID # Unanchored: with a bpm, blink from the taptempo - # phase (on for the first ~100ms of each beat period). This replaces the - # old gpiozero hardware blink() with a 10ms-driver-computed on/off. + # phase (on for the fixed metronome window of each beat period). if fs.taptempo.get_bpm() > 0: now_s = _now_us() / 1_000_000.0 period = 60.0 / fs.taptempo.get_bpm() elapsed = now_s - fs.taptempo.anchor phase_in_beat = elapsed % period - if phase_in_beat < 0.1: # 100ms on-window + if phase_in_beat < FLASH_US / 1_000_000.0: return _METRONOME_BEAT_RGB, LedDisplayStyle.SOLID return None, LedDisplayStyle.SOLID # No bpm yet: fall through to the default renderer. return self._render_footswitch(fs, beat) def _render_footswitch( - self, fs: Footswitch, beat: TickState # noqa: ARG002 - kept for renderer signature symmetry + 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, @@ -707,7 +698,7 @@ def footswitch_loop_progress(self, fs: Footswitch) -> LoopProgress | None: spec, plugin.output_values, beat.bar_phase, - metronome_brightness(beat.beat_phase, beat.is_bar_start), + metronome_brightness(beat.is_flashing), ) def _bound_plugin(self, fs: Footswitch): @@ -725,13 +716,10 @@ def _write_led( 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.""" + """Write one rendered frame to both physical LED outputs.""" if color is not None and style == LedDisplayStyle.METRONOME and beat.is_anchored: - brightness = metronome_brightness(beat.beat_phase, beat.is_bar_start) - color = (int(color[0] * brightness), int(color[1] * brightness), int(color[2] * brightness)) + if not beat.is_flashing: + color = None if color is None: if fs.pixel is not None: fs.pixel.set_enable(False) @@ -1410,7 +1398,7 @@ def _publish_bpm(self, param: Parameter) -> bool: return self.set_mod_tap_tempo(param.value) def _publish_audio(self, param: Parameter) -> bool: - """ A local ALSA write. No remote echo, so the send always lands.""" + """A local ALSA write. No remote echo, so the send always lands.""" self.audio_parameter_commit(param.symbol, param.value) return True @@ -1720,6 +1708,7 @@ def maybe_show_welcome(self): if self.settings.get_setting(Token.WELCOME_SEEN): return from ui.welcome import WelcomePanel + self.lcd.pstack.push_panel(WelcomePanel(self)) def get_software_version(self) -> str: diff --git a/pistomp/beatsync.py b/pistomp/beatsync.py index 0add31a8a..20230db48 100644 --- a/pistomp/beatsync.py +++ b/pistomp/beatsync.py @@ -18,7 +18,7 @@ from modalapi.ws_protocol import BeatSyncMessage -FLASH_US = 80_000 +FLASH_US = 50_000 STALE_AFTER_US = 5_000_000 # A clock sample landing within this many beats of a boundary is treated as # the crossing itself (arms the flash/bar-start immediately) rather than @@ -113,12 +113,11 @@ def tick(self, now_us: int) -> TickState: if current_beat_idx > self._last_beat_idx: self._last_beat_idx = current_beat_idx - self._flash_end_us = now_us + FLASH_US + beat_boundary_us = self._anchor_t_us + int((current_beat_idx - self._anchor_pos) * 60_000_000.0 / self._bpm) + self._flash_end_us = beat_boundary_us + FLASH_US self._last_crossing_was_bar_start = (current_beat_idx % bpb_int) == 0 - is_flashing = ( - self._flash_end_us is not None and now_us < self._flash_end_us - ) + 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, diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_chases_while_recording/recording-chase.png index 9a80f0692120a6064365a0761b8dec4c49abfc1b..a3fbd43ed74b5e0daa323f8f6483c20435b8f7d7 100644 GIT binary patch literal 9846 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 Mw6avOgz>BY1z(h)4gdfE diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_fills_by_bar_and_beat/play-bar3-beat3.png index ac06648c5459b3409d8ba140ae004fb37b108e9f..17f33709e14dc7cda7331c38288a28db3443d744 100644 GIT binary patch literal 10875 zcmd72^XQlcQz@K6HMAk7dWB@NOg(o!Pb z@9p=z=e*~0{({2~GBdbmuf5j2uKS7=p{^>Ak3)rnKp^lH6=XFLh#L>#^&c!W`1eYU zLjr-IM<~iZ()LQ*p2OB7vAPoZW~N`<{Lkq&#^TBfGbs+Lsj1G0C%pX;mQA%mwZWX( zkLhV8$?iM(mwTrdQnS;4S9&u8&TK-55={b5PuDV2O(@VPMDQLFH8(diFfceeIy* z+9P*&0b>sh(y{UJPnv~a47O2zJ(i7)jkp+S!SpA70bX8SrlzL)`ue`Ujqz!GCMFBd z+h4D&tSq8;))_ha`T0G2mLSVm;ZU`|+Fxbsjb-{15fl{k{P}Yx?=pgCh1%oe<3hs1 zo9<$xvptsQCntD!#XbzKHP+X6bamT2e+{nMDSHj``>Km{oDalyA1)Egv2|-_R&sbz*2}XmXN*lvKce zk}r-49Sx0!w$+G7n2}M2@x8XTwtKpvhQ^~fb6Hv0r%$7v|D3+Mf2yLQ(#QVn*)x`2cX#(U9cD#BscC5!-#N?5^@<(W0`@w{ zbxVyl#O^{#X~H@y<^6K4hXOi0JnVDDTi7F@7E;a`7ExQNO3%&BwVx~xdW%FMP1Mzi zC@B@=%=PuBU~JcyyMdmKj2L{*OfgU5GcyOxDzrHgdbmQ`)OB@P<9iN#P>L31FETGV z5|juD3HN)kFoH|jfA>UFK2Xj+E~%Xt^F1nN-jp%4{%LAZ@^N4j7NP0AI@>p`b=LNQ z$1L#?9cDs}U0-ASEu&-^am!A%G&PX{rxs#9__x|WavO3*Bcb?=7#SJ8p4@v9{czxO z+X)RPBV%^{%|C4uV~6v<#6(4t^^d6VUe_)JocAR^_(+HMp%tMgUogAxLRxikLNs1{PyI6>4lu;?N9xANtWgU z5Dx=`Xhi{E{=YwK?^9Es&K(a1UX2ciFWI_c_79#%B~p6q&a1GdW*5lQ`TuaiyhSs} zBt4|f@$AKm7t(am59zzVe;4!mW2&gA=;l_XQ{>>}R90Lp;r(~{DKARE_D4a06s?tc zJ_1=|Pw7@QaPdj6CC~FA>21lIeB(~xZDtkYWhT6dz5O(JavI6W$>^JF+a!PZ(VcB! zBV3!SwX>yLayjJI&8FdOB%p#wE93 z&Fuzw9&PC-D}U1FaGGnVc3T_x^XJd)dr$a0zxW*5?%QA!5-y;8Jpm6@^K%yZ4h{~K z)zlU$EqcRn$!%?IKPctt6eaYooSvS-ZKlcsFYV&*RY;&v^SR!>zS7qh`}wNrpG!)f zaU{UqVfX!;L!DBiDTi2Fp|o_VVv-7Z?7pQvCSq*2D!0|X3g@}m`2eJyTS~QPTL5dG z6WYm0-qXy$EAI)s&)OVXT3QacKUp)p&^+50N(QCQ@s5pKW#g7JBz+rriZu(7J{!!v z=w7pRF8%%e#%iZ+A(;Ji*U1m8l4_CucUt$?26fp;B^`2`n*&z+l39u-ojqvzq$kB+ zSq-H1%zk<{(Nk|3h@^J?zwf>il&_o|_x}CxOR>zsg(@pGwREl2`+-*%OI@*1ve_AA zdQDz?S&ze=34eZ>K>9jtjN}=UukLxJ3Om&}Ph@0d$j8!Jc(glIl0O(38S&VejX3*} zEf+;_S1eS1d`suy!`28AR`|==_Llp~+FJNlU+%B((*xOOLiv(js_WtLW^&ld8XvSd z{C`ET&d$w|5EJ9z;3QRjQc1NOdvV9a#6;rF1D3?z2M-=}n7OE{4|T>+CvWoTSB4@v zMMU&+hKH&E<;l48e(vn-?C!Q$^peH5xLGQhMrPyPYEN})rX(h2B*LN%I9cR2tohkd zh-m8>F{0C9I&D}<(JG1qD^<8i8=2#_s{CMleoaXCR$47XBDsFD+5bGr#B+DvVR63NV7qa*d1iFh=iAoc%{6r(w_SIly1rY{U^0~71$X~X-RCsZ|>{r>MEUfS6+jS)c-0;xZ_jWIIC!z zDr-k)mqJ29f)6U=DiSj{dod`Lo1BJ5Igl(uwvXFW5YS58bLZ!y?2QQ5`q^c_RLaX? zSsR<;&koCW3^xy83PqM|?Avkcw{;Om0cN9C(|P+mT=YpV6~lQA~`F8#MFHkq7wuOG;`m5qtai z@RQ@>;#N6_NM3&EOXZ1qvL7ye!NAA&uHTXb<2Dsl(h2Z=`Y94JjkZW*;(bln740hE<~LdRt3VBF<5a-;{!??rOeN-&UZHkfkDs{5lKS1 z4gQH^LFeHys(W|(a;nmT&}*`fs>5!gbbGQQCoAiQyssMJ4!3d{rS!!g^!*}5V{089 zvi;4|{k8WY(Cypi8`TT711~pClC?axr^mKWCNLhETUh98_F5RKR*ACKDIgwPzx}nA z2FTyX?t8qG=XCVVY!9xd3%qa%3=AX)zekD#B@!1OZ{oEW1{Fgq;q?x=`LivgPkri( z%_z{hx!Ku^i;H=m&5}};yTu(e-%hi_X}=O8(9qB_{Eze>Bp^TLPDD%k9sz|JG7)y1 zJvkUv^M&@{>suz2Z8i4eN79eBgM6S<0h7H)+cU|5!!U-G-bDDX6y)SNg z_F+26$jEl)Q8RHq^fRt0{i1DCshS2}xXWv6U4E~6zIbdKH1&H9ogv2EKRRl5--xTu z7pYw{X@8w&>84uVrReeyd-lhVgS{@gRFhTMxJklJ8H!V5MZybyqsxzQKj{>yll`x@_5#5P-4IDAE+1@_(bNFHJ@>C zaoMkYPgwBVR{>&ZWt9&Ez}}vVg9B*T7J%l+=%@b@TN_2p; z?@U*7var1Nt{tr=7$P<(IjZQQHLSAiKir%U7Z-oVF_tdwNkAjQ=dm@ZCVhF(Hfq-i zU>rQH?(9lqGD=RuN_kyM- zBbNA$!!zImK)u(Nm!TXP;NHy^+X>a8+mBq)%7_20bt0Sp=>R6;Kedzd^MyK>rIkdJ z3;~;wp{&QEG;j0s*Fv#LpmKp&1P7y?wB2wPV-dVD_j`RkiSK!EPAn<8wwgX#Au9-&Bmx z8vn##9rK%6#cOxVvsgt4G^7Td!) z6O`hc?TtF=m0gvTzK@p}!k84}fO9{3^e7f6PqBVgN=k}y16!qvjEszt(JZ-%Ul2{3 zQKlYrxEEb-f!)9K^lb4t9P)9?Z+cQ3V2NF^bWRSt_~>8%uUizA zHK(Mep7*e3l9H1zMcluCe`;z9O6hYC=M}m}?2WD6U1^UkJ*i9+EvbOtjx>H zD<~+)Cn%yNit31r{hyIdv|M$h?X0b>EiH}C&eG7)sU0^U6lwpnN*-J*)30I?7ETPn zr>2JO4&2Vw&26Gs-|h6TMS_yxi_U1E9Ne6on=nfUW=<`aH402$jT@O*SS&3qL3#dm zZ)j?2ij}^qYiMZLq!qTGB%-2fFzdka`Cj8NGd(l2x7l2zQ=;_rsbP)%ROZz|{{0u7 z0zyK~=R1wS=i*sZz7Gr#5D@Tia7;IOSIIJF>4L-={Qg}@LE)!sJ@Ckj<#@HE$Bz$B z#1^~X-(yut`L?zn6%|!x(&FeEB1U5Z2*Ad&Nj^j;E36_prmj*dVhaWS#=Qq+?esKK-g!~R)|O)q#kuo{ z$}{JELc&WJ@b*k?uX!bqMB#a_klvMnOzDN!kBM=@fVOk6v)kC&&7gd1AJThWovtnT z?n*|;Y8o2)UYzXp#Iue{nwIHRWEU3NfJ^f7<;#+i62Np`9v-2M^KJ4Q66W3SVClnO zzXq$sq|P~LYWp6EoJJoq814pK)@7kaCaXLvD<~sx*xH;wIW{(ydGqY#L_k1*&m<6b z?M@BJ6lOT5=|-7ZMbcZOgj|Wv1M!>?CcQx_<6l|+`RXaO7AZv5gGjole zigDkTR9!^UDqeIU>s`aVFH;i}$;rtlE+w566uKuXUMR*D7Z>*p4hp*wdC4-K6{+U8 z=ZCj15@BQwTPNWbjoDfzI$Dl*1a>3`b~|k1bP==2GbbqV8aI4vB3at=>Qi-~??K<= z;v|jy^T+wjE+F74MbIwE+mMNPSa=P#8?+A*$8daVGD^yNn^6`CMhwL`7US#Q71z~| z+`H@z)zxFUzMiWV^gG*kj|p}+HhL_p)~J(DO9BXfPEP7wq^_>492(Tlj}FH8x-Pla z_eDt1#(G!wml+xu*z)Uyr7Pr({QPN}rLJ)8*SCHLCk)g{-@=VJ38Fz)04ZRo%F1~D zclx8}lai7Ei~kX4sa5C}1L@+&sN?xmQ8%#Cn%p;tcWir2`I7L+Hnz9t9O#Te*Y!0Z z&Mf=;ct=N_#^&csCpHObM9KrN10L8kd19xhrAfH2tLJH$n=jv++pwXaWeI7cPwWLp zYn`2jK%R+MewE{ps(QbXU(Fqii(Q4ftZ+>XGLSsXi$!raa(;>qR>{d z)S-)diVKU28)m65FE0;i0hBC(P4x2jk8rwxldoa8b=#nb*}1vX1#E~>Nr{OQ>%%!J zwjmW~XFjmK#a@Smge0RQ^yPAf!CqovW=^K_K0qS&y(DVq^(Mu=6%`{A6LpP@XkxqE zJU!{810>`$Se8T%bIZ%s>4^YdeSCZ>^|*o_65YOi-{-*E91{vkT6&?Y%j)|FcA2bq z89byopc{WJErDa?^n8g)p>i|>Ap5WllMW06OArjuW)2Quq8(;xVkjT1h7d}b)~Gl5 z9y_|YxOjNfw6sXOUHm}#9={F?D*@nOBEEU^ChG5ZPLK+snv~nXTe!&VPXDf;1<^}L zNQjHie9g==1hIgJjXhQV96i9An2JiwbH^y&1^gN;#u7w3|M{QP`_l4g(X zlob^!`kn#5k1fJAqk6!Q-rg1eTvTMipRB-ygOC4=jnt`W@5zJ?jn!a=|4#iHNc*bo zcA!!JNOE6aUwb&=(pqbgZkeUd(l8To52(=O^mIwP@nR6dEtlI)gVNVc{g@^i8Wm<8 zk#NEBLg2#O94aeo@p}U2KS9mowyVS?e86f3k62T41U}FyF<4oDaijH0 zQ%lQpdwK;etM}WAE4)fbN%_5wexr2&I_<{~A3}{(;R1L~<4=m1oB~6_sQ9F#&S(c- z{m)0_Ju9v%4e~DdR56{**L*kl{ur2@o}cp~n@;{LwgbLp7dS5D-7+QmeHj)&7A2?g zq0O}X`DS5Zp@xPAVBSw>5%&pS7KMB1s#ccb&|;O9mA@<^5EMUdD914cj2Y4sl~3uo zFMz=XmL+gU;FrwAJ%Dx!aZnFTiu{ed@EaifR!9&%)&pY_@UUq_oatFv!+Td!c9(Ep zJ?b!0{`D?Z&~6--A@H!sB-y>z3VT-N=>0u;;2B3db5TT*Z{7e;y){RfsZ#_F%v`P0 z0$4D6I}LLKK-`q25Qx5;d<6L|7c0D8cx))-!qk*abK2HNWZ9at2sqUTk$v3Yppxc5 zj&}M>GsOsOi?R_5z2D%+}@@11A8OpDg41+795PwYA4i0sGXE*?)#K>jYR7 z=0ce?2`4eq1yP#M9lNJ#?mvS6$2FiKhvrg`J+=byyvA0#x{labC|K z$Q8Aj4sE!5#Y%FcBg(%pU@FGJoCEy(NnH!KoW>x_@&h7}as8Imc&`_(j%tCyFv)3% z8112*n1r-F4BUpPQd3nWz{f|qt*JuOy~b;}@hS?FPsSR|5{ySp`vc<8josYbHmMBi zZgc`=iKUZ%i^+Ds-av@ZJ(GT^m_AZ3}X2oK~l9EEj992G^FdGFyN#f)5II@h8fZ&M`J1_4-OW-v)+q(Qap^Jgk4KQFaLzDxmx(L>(;RQ5=-swuJb9cW>a)s<(zi^^c& ztckr&^G!BOkIJX&GWu%9ZcsEC)z!!0vf{rAI*{T+{AZY3bY!&nSuzIEDoaLA4)b?K zK5Fz81_0dl?!OI8>CTDU+S<|Jf9?U~01V0%SML`umL59Ex2?Fcs-~TcJmpr~?h~xh#s@9E1%w!03nrK42)`vIoMG+LOrhcY~TYMCB6t9ACJ27vWBB&9& z==-M^|0)2IF$*xWLCeZy)p_mxW4HgI8z6jkYl1t>^vCqMv}|pEKNyq?}+l9bM)fPMuKZ?3NoZ64kVVc_J%HQ9ShBT77ID;TGg z2V17MySuzb>jW$eb<%2>keRq!uk7f3@6w&jIJNk1Rdm6+pWfDN2=gsv!uo3NXL|O4 zmhNO9Kpvb9ms9&i7n5c`%9hI+a%nUK`gki9UD6R4JzAvI<~_uA@wZ+*rPNzOe*xI6 zl=l~wPl9%^&kRz&L;T{spxZK6S68);b6^UQuqc;v+aT(qZwN`1mzC{8)egRU_paV; z4Tr)vZEC+B4EjS=RMws*s`!#P&Ok9LZM|_VedY;GXHK;n$jzX#$8_9qXYd_eUU0Yp|c<4^^*S zLMOr4+cdd8gEasZ>lK7aNp}LCiZBIUUpT;cdpM+Tj+5oj!E6mDq^sd`;1gqT_D2aV zLtlF4=vdl=KL6#508J=(bl;A9K9qd_{vBcrHQkVxlF@J8y!rf@BVDx?WO%a@6{7Sx zBAW1G+|X&UXtPu`Q|fX(C-&$IE<9py2=qpZZle>i(XjYit*^Z4C=f#=|Elr^-oL7( z4{@}(K~eX=i`C%f|0(JJAHF&x*lUSplEKD0WoYbuT+nv1Xc0QUl{lv&S5T5F3WV2B zYGMA|$;L*bXbg)1lq95#zPqY^6`SXuJX*Ol+0h!{V_5?Ng6T8p_s zfEo}Ga7m6T!y( zG~tCUNy~6shCehwMj_lNhF3T=K8{x^JjifNE)GW@cEj?|+|;1Mq|e1+%WcuI;zCx1ahoYjc48 zGiF;&L(tXR`;?2UNc%IW`vj#hBdi6{5#Q^`so-@vti|Ha6<5W$FMf0)v42l5y!Thx z6TW}_D&s@8|2ZU^4RU=u$D_SHN=YAgNY1UT3uLk;>s(&!$`8F6YR4M8bP5iWi*Fz8 zUE2o_peve^govnh#WgiG6}l8O`Ql;gKDrQ@tUvpk;W#~+w=r(h(hitaeDCcATYh81 zwcdy$0cdboczEK64|U3-$|5R0yNRv&zS3rq*=A;DEUIY~R8-#=or~v50MqS*>4A`f zFD4k74Z{J%knf6UGc717LBWUMEc$RHLK8dRuH7huklCr(#zju(u!Xp8BD+U%L>z)3 z#dwxv<>Iokg=T->vitUm{O3x}&gCsFEsBnxurY$WySsrPgJ54K4o+u5k&WpuZXI^t zi_Oi;TTIIzwk}2;wfDv|u@hmf-$^zEqvT!;#J4b}@zEn{YHBdYadB|?`T6bN6a^(9 z^EADI#sh!)^ob?r2?;0)7y)GRE=!%pfQzE|6?$9=N{#*fSXoC$9);Q*WKqT}uR2uY z=jYKW7QV{K%R@`waBtXcKIf5^Hpv;*%Sd$@$0H;(sWk5aOV179PC||&!EyRa1TwmP zQC3c_AJVd~q1X_{>6Rh4eU7d9C~6$%BDZ=9N6zOw7w)$}({rnzjgH2vNYcpLvG$Y7 z?6*t}Jei%IhPy-A;Lc+2r?aCzN(rWjz2q2BBj-haaZm%x6p%2H$2hx+T~0$OFAn*z zP#f9kp;Ds5YgFfacCc|$Nh11Y^b>h4_Qv3JA#zbnuVmba87JfE#5jSqy_J&Ft~ehB zo&+r~6O$~&*L5!Fx@$>2wvxuS^9?VHnwrw+u`C%L6GeYA?JR?WfYpPF2iu1~8ShZs zN|jeJ4p1DV4TNCbHtX)k8Imb2>1}R%ap>s-$s=|eD;$rAMCeADvE6t&UM05CDLY;q z_(-PvN1Qk8L}jx9zoKHmbQw5^V-kgk4^BW_o*$=P?=K93{ivG1|MxF{RdixvqH4Zd z(5BV0`Np=S0#-1D2;e!|3yBK}eH<9=Q@+@*QHaYffU^S-^s1AFMX^|}S_W+6U_O?W z#o&orcJ;0mou5y8fTAI=+WXZ3^u5tz`*YC?2_iRwx`8M3MDF6c^h7@m=$1KhRpXIS zXcP?YVE6^oTMlNVzkAoZ=-gl_=vP8PMpk1z%qT2eTbdY!+6n70lY8<+>U24tN%5b5 zp&xw6%Bo!UkY2Ce)dDyQFd#cy+jskgMMYYL+JF_t`(GZ?W8vb$JJl1G>tNrX5Y{|QK)8D@ba${&{2zaqFXzbi+Q8gvAi1H)=!VGQG z%GG#!>|b=Z=)AQ@Gg&yoNfwpJYcgP21x5_WbIYeBGcAKb>G>8TrccW%h>Nll!~PsD zf&hUOnMEaql$7*aTJO@{A52eAPuf^3J_>tBMc(%c;D%j6ICMaTsyX?&cJ1lzj`!i< zHCX8u7KPfM!6_V?e%+5^sd9pMadE`6LP-JN^F}wQsHu%BxxaiD9kH8xMkmGfUg2A@ z3$*27+v=0&ieMXq{N1b4+IM)>n9ZidleqM(?}KIFNju%wuV0l}#((^gSYrlu8oAkravh=Ico zzs+K?C-6ku>9*QF&Pxd>{2I3zp$@tgb{u%s5+068g>g%fKd6G(j<3$7`*6)pj6gsk zPPALJxB>(5v+YpMu;1u~lUEXP7Dp%nDNY9@ zSi6UV%%ni$G`Yy!L}*HMIOBU9=bIE4j0cnMhm?LF-#i3w01VWUf`anTI(T?8{u7f_ z;nt)wYcXxBAwwu#lm5T5*7KKxRWWzXgcx$=t>0I0_OOKtF&s$9xL~1djV$9s;MCK> z&||`z7#QHJFEqT2>Rma5pOX{%P}K~H2=XK$hZF^-&xM8c0heMZ9Z0W%$wMp+7|lc+ z5gV%p;0_5nD3xyeB$wlzImq2K=!uk*`I#Xs=~}u&N-DybU^;}9Nn|*r(p>D2rezxd>7>Khx+ZTjBi zBI9IW2x97iFlJ(G%%*x8Mj0826HHG`Ox)01J~BU#G%<-?psKa9eZ@(C_te@ud2e9} z>+b!4ZnM7Wn4#LvoZkCK?lTcXGF_f^@){qIW%5`MySTtP1lVdHe;h!_G<)=z@%<2q z3pG3BhQtwDem`0h4qqbV84v3A+85_PMhhRAMJ0a2m8bMI&F6ls5KQEFcyLX`AwTe6 zdHIkjA6PSxH?c4=L7#!=PcaE|tPe9;=~yRlS+5rWGm3)Z4Uuc|6=&^0kXFLi`b{7kCa6~iOqQ&Mc}MJFlU7$w8i~Z67I}D+!_m319+`oEZqxuNQ``WP5FFP5Kml`1|<>%5k82)tzHr0=kYG~xeT3wE?PK=(eqz2F)6|2G~!8Y zUv#cFvb-A?C0ADT_5z~;qkLZO#UzstC8328|7(@(i9;Tqhq}?92}<`=>++kKKQbOL z{idd)NYz_BJ|CxV+$2V^OwG-o@A7@!Jw9yb3?M>*7j`T38hzAawCK#`8Tc;U*X~A8KoJm?E(Jkpq-)dNDP0muhlDgzA_59hQX*_Zq`ON&8tF!)ySvWx z8Rr@2jQ1D3eAuk9W34;poL9^{R7FYp_D#~82n6D`tc-*j0)Zw1*V|av;Pq0OK@fqU zM#xHtYPctDOksQ7)x1P(^F)`}deE*Ic!=wHyt#g-LsjpVmWd0h@-2Cg7@8!;Q3K_d z+_1)*-`{-|iP5|FxYEZ#GGCJKQ_I6c7L>}1m5=>&iv2W4U02VgEZa&Iqx)zSeP<%6 z+9&rcrq?|87P~!(uOasNqDsB>aL#7zYo&W`z*v@DN)89R$JIwl&T!a^t?ou~#$nSx zdgQ&`gxMBJmC9imagsMWoWh6@y4AwQ!^4xNvb?z1y5Ph|jw>dUHSm&?H0YyR4((Rf zkFm33FCV;3+uvU$dy!{MV#{Hy^~dWay0)L0X^oSpFtJ|9;i*QYBT=duUG@W-p1Ei2ZGgg zNH&U@nc1pLH*4U6ly%I;($dmj#Lt8{wHNQ!t-ZZHMpjmPTidc|vc-jk`{d;MDk>t$ zY(bq1t!-`3G&Q-nxyQ>VNH{FC%aF9jeR${R=VIdGj^`u%8ZmO3dU_LsgBc@hiqGj2 zH&#~o%zoj+DCg(zQBfJ`=sfu-b9#~T`t@r`2?PbF5J%5; zPl3U~!|=<}QWfVPrbRNePHt{lj76X1P=jx2+sDUsQEDqoOKxX}ww}jp0r=m(e_yu> z>gbUBC^Nsdre|PayU}AmS@|(OUgiSnyolrco3-IQD#flEIp6q&YWY<)8IPSx!_?$t zFfPUF`ub!MLFdFoOHUIH&5?*15WQi%wNTLqi7#2VGw8J^tRt#T~XPJ5)cJlvh(z(<(4j%rXpUQqj=3 zcju1(oI7lvo!w@n@a5=A@CQFp-+rsIpr9ZoLis$cM5c#WU-R>o=%Pt*ZbWxXUwR+q zs^#eG>vt8<;A8OzXEWjnpBKumdM_orDWxK9+spe1N%uLDm89ETKFV;8%RIiP=Df_) zawtw09s1LhyQZ@%vfCm|;^D2%^CwT96f|7&CwpMO87(vk2+AQMU@K&uot+g-PZJM$ z==q4fzU?L+-j>z%)%ms6YI{e>lCCaK_%VD6|UZq0;4!N6yfH(wmDU1*%|MnskydDJx{t1 ze*P3M?d*CwJH^rcH5fD*VlFQ)#nL{=RcIutgi4s6h@W|G)g7(o7MK#3`&@W*rg|n2 z&m5v7PH*o2LW2tPKmJA|&(FPf-L5XSe9z8akt<$)sjBrojyGwGpm9Thvz8m)F3wzvpWWljO9|3Z-o8SEwj>eb}mVXejxjlgTvX_V44z*iUU!z zxVU(DFk+-se`Qm;Rre=gZge_*Ykz(}^4CFWxz@Gw=|LEYr+#(pZ z(j$`mW6P}9$wFM$;KcNFz0bvIU?2t&5z(_}Bk}R^3!QOsF)?XPH=T@)DZ-`HwY6m; zX9jX(ne|%F*XUy8)Ya8*-MqQXhmxZ5-dEMqij_(i4M;k%{1ef8Up-Ud1Xh!lAXFgwvutStM{!r93xXD%)-h8Vf50WKaMB0|E=v67{tHcAe@yPYi)8{(fsLfYh6>t6l+ z5ek(iMs8?&+R(t@0#=Y!r#NGPa>%AqKuDXupNx+!vdtQb#HQ=%{tBU8;>^@S8F!`* z*~Fqld>b6pm>}3zR~C|VX_?6p@;p@WCSsfAt#@APiD1kf zS{*U;?B2x?v>MFL%*>3a@VdIZSnRzT%u(->E=ZAU;_Ov5Axn4o^NGf5U!}T$WrM;8$WC1tVA-*ya zxQ(FG{975?RjZ)ady5pj4t}d>Nr#Jx_AJ_kU*K=Qw5xW> z$jeWZ+mfxS!dQhI{vf!DEwa44y&Fyz6H7}=ku?GouRT4}4K36h&224Jg~$drh3WR2 z6pJ2W(#fhZ#2^m_)q8q+u(7cvB_+Eg@6s!ku9m%vizDKC%Ymw@;^W{@XCgd1J1a1# ziLKf=KiON!yLRnbsV;L}HVS1v{9Olr3@}5+ZNqjVU`YozjiP>AG27zFSz@Q z20r?tHBoHdS?_(8;<1`jS;=c}Z;u;7Ll~y6rq+`xtZZ-1MjVVoVyfxhZFUE42JqS? z(w!otM~#ma)J$aPE}Av)J8W~hu9N)P_+rg-TdzMm?mIg>>ajqr&6r;;4o{zuT?N zZU)`=VCY#_i_2$k7gTPiwVpx=AKiYd;!489QhgR-_ibvSm;E7MLPA0`J>_L((|kM4 zihZxnX1Y*NWzv16rKExb163N@!pYk7ZI3pmy4ooOT(#X_B`Y0Gdv3Xu?KfdkLCM^U zVSKhZRg;NCl~h%Y&HE&h zT>Sn4MOm>NWlUV|vZf_3XE%jgYQkrM^TNi4lO(d&%t3L{Wv#Aez(|2#D)RmN+AHrH zqxS|pXzBL*6^k5878flRGfr;(X3k~6;iuTodmh@e>+L!ev;`r0 zZn=faEF?tHw)5r77sW*G15xJvD(9v6XrcZ>qLL?>2QmuFs6r+ym5QOR*7$oEd1qbU^w&b%s+Dntq9YS86@A>%n*l?4z6n^Wz`duNC`}fUHJpZJGzq}y0yZ#t0ao>4r=h1}QjJNbv ztQ_i^KVoe>MaVOoOYw$&5{D(Ov(s#I(9!GhF4CVs9`p1n0(^ghRw~)c=`AsuN(Gy1Sk^uwZr({?xEueiqs*t4-UL%gNV8`Jah;Q zIb1g<$A*Wcl=BU04#zB0T^t>Ewzst{Bv`-Y4xp*TTt-@T-OUTD1ZHD{DnuEp9qoH;PD(r#!mt7-{>yE%^zXC zS?}SQ@oJge)3dLW)Xkj8{6qntHa90X91QCX2aj}NcO~)^Owa&#j&4xlo}s9*4V8oe zRXWVRDXZXZSj#mL|8E|JkRMOic}`T=^EC7*GF0devNR!B)n<>cgmhSk|kRqyY=iY8isAvC?YK9Hp>?7aA1pJn6) zXG}~CtmNhki7(~o6(!>y%fq1cU5dgV4IgsL99iFgQ+$nBk- zD%XwiwSoqu>-y*e@n8r%U=QriTcND5soSfn-qCkJtVhTyie$m#Ti=rm%vjz;6e7{$ zK7A70?-O@e@JlD%M)zuKYg10-W(M45W9#efjcl0(=+V&Byx8qzm5!to@;R?GEZEv| z#`yo3n4hS1FjOFCm;|>&esohWo+!EBZl!;5e5dvdrG0|oSYTd$Nel^u{1V1I*a|~ z-@}K~Y}7c-)L9uB=RhuddwZ|uqm||4<#Q4s1jfe3{vm)Oa);_|wL&A{9F26u{N4$y z8kDJ9Lc-kKuD?D!0NzUxQ|`iVASrph1e1@IwIDnDF&-Y?8|u%WKSRVVZ`D>$4R?3j z9&gX4q@?ghFy4C?6&01w|Giq7iI5SAtcQVxQ27}59337W?(f@JS-p#l%L=iO;z>{RwUuF8hm?)W_R!3dpx8VJw4mvtIK-<1Po2Yb5m!bVy z?JBFF(ACxT{_WeowS()|uR~%M8LR?%NWEty$KPfqR*W3=QXgmiE z4NV9hm7$iFmYQ1g+|HYTfCs#>+2x2QlT;!U7g0s zZY5=66Sov%Qo2*q(MjbF8CE$hK(tdRziFET9xj7YTaC2V(<7TI8`++1iIFo`RsATH z{?gjI#^Zn?Vy-3lc8S?Ud^~ZC+`(S2u= z?(Xh?ENqh%7Gj5mo|abQ*|UL1mKLipexsK!U%I$(4YkK=RVYcOw}NGwg_VH(lPGTj7)#g&zp?d6arVDFrqc!KE3O8zx1{Oww7 z%Du3kuU1iuFE~jpEMMWz`@6>p2@0A$B@rT_qEgN>w2DxmOXs*!&r$-x9!|~|^*KrK z^)6HerWm;@eLD?}K?w5Yi^uog{rzh?QP0LhjB|2wvhVXr&iwUjzNxYa;H8e_S*^)` z9e5$nYhSGpl$`6SsNfS3jht{e7#sij^^wMryx{wH2!2jyg2A^E`x%A>S$23KElq3+ zxkFE%4Zq;*Vwvi)k-xZadHJ9skC9wHJ}E|nq0V9U+MewDwmEizGJk5kA2m#Ioh{AH z%k7X}|9I+LIb9o?+H~; z4GeOjs01_>0!Y?*9(~51E0A0Ud;+-2e*f^{!^~HixhC%MSZ`j~*&QAnpq2o{KFXNM zVw!50XQrka;YBzZOM#msl^z-zy7KC!g2H>L^qg3BMnp`5Zh&`?=iA~Iw@@{1Td}dRP;+6JY;DR@ zW!BGt%mC$+c=)lQ;fl<@)`X2Xo+jpZPZD2?X%Qi_E-gM5L{f1{33Dw|@;~Jc!MZw> zn-fPTDk@4sLP9r3JkDFG^Nf^?jMcD~`2Kw^AYgB(!PYt1Tbh`dU>?&-Q%Mr?#(4bH@>&^Ame ze#pJFwYBx102L7MTpi2-Q|am-lj@ge9rM}+Yg510-O<8g5tGX6&-yykM;V}bpAr*0 z^_NDX6r7x#DoDOdrLVO43wrcY+Yk$41$1q7m-qeSEyP|v%wilyO#%cQ2Ve2$n;hb# zd0*IHzEa2cB%vZk7?a5~{+Hg|2%Gtw0ZxGukRpEm@r;kBe(6J2^78Umd#P7orDitt zUiU*5y;|u9A2K5&!#~C2j+j0R514fH^p-3As0(iv#D4wY;9yb`Ii^gt0(=Hif{UxH zK^eo(upEFkBZGF{0dODoeR9SUO@vjzW%W~ldBZ)HxPQ~WMBYMp5cMoLDOO86;^^iL zKe%E;W82m>JvlxG62A4PDd1_sd)juT2Gk8>{ME|F;cG+Oi=!jwA+fe7G7o8wjTUS-C|8nt1p-ZC!Iz7fzgH0075%N!ma z4p7?am_&_5-j*;$M1YZ|!GQgyk*!N%B#CV6@va_?RaorHZ3JM7^s&Nh9e6O4}u&}hW1+oCt(BLfpR4>qD zMIQvB>ya@7-^Co&)t6Skr^GlL8ykyz?MmRG;)cvEEJ)h_nhX%3mSO+Q`Lh43WLQ^* z2||RPe`Dp{YWq_mp=4UwGTl0-g^tEX5iqDt*o(B+XJ>b^gfBvAWvyO3Vc>zu7)n3B zcY1PSZebyZQj4M`V%FVXGrCuD{Q#U%UBl3Y56SLm#xJjd5}}oiIvU6`)YTn_%=&Fx z-1X}hHP%-kDnCsVw%CaY*bs!~>eu{5=%RBp7{CXKy)#^vAFF0>x`LMexa+0ILkyG&FXWZ7?`d?CG4<73!{}~{yBIq_NRb!&1GKVg?J>Ik zr^2H6`({}n8X)vlRQdo>zvbqhS@hjTkPD*G1eV)Py)zlD*{T-+{RLV!LN?6GkKLp> z5Fkwj{Zss>*zWX>QQ!jtZksC;P62m zXORBM#>By~2h5pP7I}F*2VAGw@jRSoTD!^#wXl_tEsW`fOeH-%nXqpF2iR?;pBM3` zKE*#rOFfK`xff41;t-M@J`O((Z#r>+56dn^1MAnhc@zSS}ke>;z5G-Mz6j zfcC|$sgL2m^#*}x+!TF8h(ITQkB8GJa{V?Rg1FJx%NTKkjhKqq&;M2{<_%*68Da(< z+(!CTuU%-2J!WLIwzmi9Tk1_s6>wvDC2~vTXiBS4n<;1M`O789QjR7^!(*{4 z0e-AE@#Gv9SvG32*gVWu6upuC&r}57dFs{q5|Cq}P>DrpnU#A(&X0mk=;qJ|8gh30 zh0oSYl!Y%ob)+&2ZiNxCsu^7!{*Xi%H@s3?d|yP5j#1*h%|XSgeb zYqC<@$B!oMJr6K%L=+5YD?RE2u@Fovo1?)GS!QXuq|}Bfg{2tJCR#88OF6pB@c3r1 zi6AA!&Bo!z1g&iSZU>{qAwE97!^NpfouTpts*vvm0_WXt?3@vqe`29I=++&vNu1>g&OP4NmM8y0f7@N^q)U}{%w??dtY;5({f z?fB2UUZVK+YU+1;G(B_`e&^$&@v%9$vmx0K=n3d?92C4T%J?mRG=zcwjz8M}4bi8| zT7>@%(SS6~{}=f@TExZUAl#F z4VfBw1Jma!=;(-PdHCvY1$saAKAo8KB~N-?9R*)0H~>gbsV^qPi(6Z)gkdXR!wn0d z#|HgwT%Gu~b?Vv%dgJdib8`s9E&hLJ0jeaepA%c`C4kN9;o$)d>{(tVdttHe@Pmj9iQyN-1m3ffptYDi;P1mX}o-DIb2lci_ zuU|>yI)6G&?mky>{M5VD8_zC|QoBz?G!Pb#!i}QkB2}y?8XFkk;O6FrJ3M|Y!x9J0 zhT*T#Di6r^J>5^de7wBQfzLuy1B4eyI3J(OpJ7C0y3ED&#KC}aMcUt?V_aHZ-oKZa zn21t?{`L!PZEYQ$gqHc1_^H5=<39q|)1s$R*=3_ZkKhUItAGv*X(>9DA#SxOVaE~h zI(9HLq?C;Ua?SWGKVtw}6d!662)~hhgEa=DW=Ahj^!piNY3^sXVT{G?GjG;5HoT#R zq1~BY8OVYr;INs6xAz5f*Qym;Vlhgxz#{@9H~xpkXA$bRZ{Na*vh(sJaUV3=HCwB4 zG2O4rR7i-5jg|T-n5SiJI~J)+)#m6gA_7zeJ+70&;vOZX5BNLzhckVBD$oGd(BSku zv?1#sYP?wh^~!z6R8+JH)DWl*clYD{m4S=%bE$Ar7w~Y*c%UV(!LY%}O%n(`N|aiT zHj`^S22*+Bwri?ij&x;qc6MngXPx2IM#T&vAt9KtozFS$bAf_f>`nCr8|xMki;6R_ zc_@p^L%iflW@huHF9*Q=Q#Ua1Ug(I4P>#e9wHYtPKA@|)xm7W9IXhNYS_%!Y9%$Jx z5z^E&E$xljl!Le|iBQV{o$ZC3K}!idIlN~mZxpB$&*uM58LDu_h75o5CipZ&Ec##4 zpU6fr3|*xtYBFdV8iEs;0L`!r+30Lj`c-;YGQ)3*GO01)%%Ve0p9{FtWBFUd+{%T5 zD$daHF`DLf;82GPKPa1#>{W6O)x6xVi{R7)9q>0H=7?@86t4AdWrI$?cAAPigtfc- zFSDnEOY3~Kyb)m4$)NhJNAe%kq>ZfQTnkEY$wm!+Mo*qjfsI;V2sY6W%ycGLREzT0 z4LLR0{bR{Syu2KCkv~@?6Ge-4jYsU_2eKt>?zk&~!`s^%IynS>}OEeSmKhGu4FN=ixw1{B2{8p+`z)XT4OM5w=5d4EX% zFs|s%EZ2yY2GD0p%xybf3LRnGkOB5@s^&kTjSVuEpO4RliN)3P9@wsT?(FOjD9`cw ztn@#!z#WcTXRE#DZ`>aB$lu=|T8gePtD1xk*75=J41nq zRUoOv~(ktEX|z%>r#E!_pUb61%7Cy}_u7M)_|{95q_#(!oiF zGF>5eUDMDr?%Gzl?}=02C1UXKsF3>V>Kv}?+P=QNi)z)~c_k$rk!>_s&5O#{L79TT zehTdww?q%yomu;<^0KnqpSEw@xM5*#4yYc9Je6Uo_+H0Y%o#5PCoiB?u4)^wQlalr zSD%*F^k70PcY~&OIOa5a0Q~nZQX(*w23E4;9)Zye=a|x`lRw*|Pu`J@f-N4Tvv4Z; z*P`I$95k~~RO|+GQZ=$TWZS~R0;H&GQfg{ygYT7(*^Ip$MdolvN{jVRPlj&>cSP}% z>o)+5f12LEe;*j321-pwbM*OFtKjunyhGz#_IRYUE5mlpWDz%l@O;ije*V0E`&s~P ze4}>gP;MqE>E&1-_FI4||H$Y#R^MEw6?|Q2lqu3)XTj>{gPBLCdU$9UCRUl$u zYEaaGHfLrAf6F;MI`V}RR;)(#OdnM!e9`*CJks(q5ozqLS8wi|Vmh%wD1dLO&l2|; z_%3Adz<~PWdza^Xy%*lD#(gVQ80RcvSC;3q0e#m3hiUe2ijmo#M@`$o(IlX&Fz^2U z{@7T;RqG}{Kfg;PE1S%L81AHBpYRRS2_Vy>R*Ao+G>tiKd7tk;0OQ=droLK&yuEV4S*s6?qjW@zjrg0j}{NX24Cc!wj z@;7K{;;l>-7!Q(nUcOE&s=YvpF}HFyOleaqi!Ha!@aP17piHC4o`FLY$0sL>NxVgw znNoDoPr{c`=H})%gT6k0Gz__Q{hBCPPvD5i%ZKIWT6=lb%Fw=h`}XmZCuz_2j*eJa zSn55GSY)FhgW1{Hi;9Xs%WIY4p#{kGdKVu(dp=GRF1dWWmCle1f3%-cRB%LyzfybOVj=B+qXm>dn-=TJS|VK6F?(6RO#2O zdZFXIA+PKn4fDVitEn-hznjJsXnM!VvUD+nrUWNVKQKk=#dWcpflu*w*%tc`(@>}& zkMAx{PP^ON|IV%9;kmiFUCoA2RgeD|8%v-x4*L50#l4$BGPI`o!=s}wo{|s}E&|Pd zO652GJ!o@y=6%@!1^d~9nczPCKnoIIrl-jJsiKEc<{Cyu%J>ov4u#z|Envw!FE*k2wU)&ABZO5S$jg&YdwN$$_qHD_f7x+ZBad9cN9(M1e%CJbBH z+(g1PccOujQSany2ZwD4h}VLY|2e#ehmHN)s;t~)-Pjq?)W{?jLYDjKG_!bb2CME} z-~HAk^i~T!I;8ZAyJ9b~HaJoyE*wN(3{hEDR*rI4qQS{UEG%OFSK007!NE7aohxXn z2a){V@@HgVfcBW=LsciIr*+r|nQ>LoaEwr!#zV=`vB*}lZ~IY;afko?){YF!JG-mV z#rug^dE(g}yo=f-V)E6${3qUP>ijy^YPVp)>K0vQ5L0b^PNJGj4|XwGaSp82*RNk& zTctpa;|D5bMa!Xp%RS3KKg5(R7%{5z&`eWdyPKkvCE$B`4x%_GPlI6y{`aN|+wVR- z0U-omhXZ;TqK{)ba-S89Zxh6KF2M0V+>qzbC}#W(O}#XnpO$}Oo5_{PFRGlNK}sx3 z&PC$k>qeZ)BouMi?2n`}5q5TW=fz36EN6GW?YLjlL0nf`8+@A#+CX1b9(&Ax;ZKJC z11ZkM)b@28HS#$D+WxL=Jf4cw*%;0K-dpr8I>QkCi|9cRJ$fg4528iyWQg7gMsIuN z_w465_OV~W_6I`dp8HfiX09W85RTr!FebDRs#Y-dIP@yi-8P2Kd7>b zLLfAdcW))Ne7+vSG4+US9)$mTGcw`F(^DWLBM-6U$m|E9hviw7!Qncs6>>7_(>f(y zk4pw|4g2-=4au`Q(fjW_wZAX*J99)i?AOe+=xw5@tH;w}VB*LBPJ6Vl&RpS-kM9kA za}_{Eoo%_5xjrIdx}4^1y-X?%`Dh}CpTZUW{_*lf-RxJzFoi?Vyh7f8DJm-J?CeZRN?K+| z3+di;L&qTt#ZMgGJU>6DO^`2}F=)tLXkopiF6k&9%iw;G{GuHrx83jZ;tLh>^Lg~R z{-=0&MSGmVG>c11n{Let1yBzo-n7xtQBf+q9&XN5atexr^>z2Nt&!N+SaA)rtgNh{ zprEj@unpX#c}FiLB_&T!Pfg7vtFlZ1hZOzYmmh;Dudc38pFCN;$`{m1Q_|7Zo%{W} zT&L_jPK9x*r^jL-{-qJ;+~6P!BV%D{sRBLWLi;aKF)?XrX>xM%-HB*?4c6p}ii+Ia zT#T2)=3k5&3dwb?rT4Jmx$lW=TA;q%nC0eYrFER2eD^*yNZ?3L>xBE46-JMHf zXlrY~P*Bs>X28cl!=p0N)U-GnVq;@lUt1d(+aAr#A8T=4msN-gfEEV@Q5I{J4h<=s z)@5WYRlo1~nwi-uXzuIl>*?jS+yOIWO>WpngiR7+g()j5BZvahlw_-3fv3`A-#WYA zLi@Whm}FsLfip5PIH=#O5lD;5UCk8q2A_Z+Xg7;Pqin{>$%!`&`{l;Q2EXk%O)U%C z>(?!9ZAP>Sda(UE4*Se4w`M#l(Hzy1%F0>Av%jm-xoWnv_C$n)vsI>JX51~Aepi22 z*>o#CGI5cx4@&a$r8!gU77o(W)5F8jpklHnCunGBtjR+iQ&*03k&%%ObCm^oy7?2t zq@)qy;cLPHpg6vM{hA&4(7f~n?7tXG@=#tLEn3LS)Zue$yZXhO8))R)ya`(bnctz` zu`Xj$b5qlE)t>c}maTz!n=D^hE~v!a-<~>~vGiJY(Wf{EN(CC~{Vi{3p6B;$pAE81 zXRdZg(D>u~purU7rfmPjj0}gP ztT&+Mz7-TONJvnhqnp?0f!eycIlcDhbsn^llA%r5B|XdzxY^yCDdWLjs{r0GpfPkRAO#+q> zgX}YrCxe)|*c9r6a&+G3MR5~%eKT3YK?|v|9!XD0A)ynI#(!&XZ~u7XTY+q14S}VD zI`>|I30Vz&rqVq15#|5eb=!5pLw2e2KAS)YSzFflMZBO%ey9P;S6T8jq#93~ZnCcHk^Wn9HpsGMchp>*EDj}aHWh`S4)gsr2ahgm}xP*^QMW*C!zEX+<% z`(MJn6;nA68{`C@D)dJ=(KZwZy`oUj)Fgt{bG0K9BZo;4h_iIRZ)tw?^Ob7lL|v(z z#y4Oxv{L2prm?fI4Aw#dPWng(ov&Iw|8ybM9wWb|=)XLIFA3BbaR!2`E93o{$d?0K z@6B-v%HYMyV_Tc(O+jis@>Q~~iJg;^!1u)P@bD7NqCF0C$z9Ra6SI9t)@x5zSf+I* z6Y2ANUk>5PUt)xgy3Di9Xj#IpYrmV_TUEB*n*A=11g2lr6I8XG4n-0%p&|$6qMmJ! zNw{rbET9N)-e0bmHhp+M*GqwGGn~S~=)4FPi-{>>8K-es=aamO%5uE}X>IA8iQnNs z0^PC9wl6C$VqV#N4*f>mc%!&x#}Y| z_3-kFjfvslE6$I}Q$xl9Cds-0`_N@2Ns%gcT{f-W!h1?d>8h z))M{0!$Y7!US3`o7Z+gj%a&JO(fzxqL50`)&zOgY2T`iy1)FSmizi!}V}C5=!omVq z$CbH<$9Hf;(S1O`fh3V~nS4u`vE`Qz$L3+{lr6<@aXG%Y@PZa@jb;w7pIrGrL_K5E zVtppC|8Reca4q2Aq;!u%$BiH&BC^nbQ#y6xhqa5#L#z|h>B_FSJf;qEN^@!Ob~=N|F-D|U9r zo$=g_ZA>D@@ub6*&g=(I&hz6CTmh>XLZU}wv@l`$kz=enB_~={1w#D)=rkq&+SM= z(NG@<%23|k-j;)rV4xxdyq^&exE?Qyxy9Ssn=2|toA{EyxK~$|kqKL_rN{2^I{Ixl zlx7%*;`Ajp0F_@rU^k|}NT`YN-n-6tcRt#rf9L)C_o^K#649~zWfYi#ti(@A9qjCK z0|g$W5d-CDA&1)Rb)mF1^WTL9pUrv9kXG^g&`wbdBn1PrH$D*U6IxaO- zkQ(H288y9mv`W=0GkW7%?EwO)q?hAvW^sgH*) zf%&R~LqmIBcMjM+larH5cAK^TQzakbA%%~W49D&*&CL@<2TP4{E%QWJVc_+T_s4<9 zhleAeC;bET^z@{peux~mxdVv>YNM`5`P$<@DppVhS+Cp+0l%iE&P7lM%%7{HyHB9I z2JDp-4Uj`Z(D6)a%)=67xin85dI9rMi~GyN;NR`0zP@q?Blb5Op1QAAXYb%}zGwzb z(qcUft2mgi33(t@W+pc2@b?8>J~1(&qN2jd$(fLlz@}3U_6rDk9LwRG6iPc1YE0O@ zx&jebP+MEuju7{*PNmViLeM?2I%N#dQ=n8+b9de7)cA$FC+b6$;J0>bLlReiP~6WP zEYY{dvRBqgk`)S;O_`$=i^jwwW!${IJ3V$2n`Mm5uI8%${O>-R&qWIWSgLCZvl1Oy z?TN4%NiWd#KDPz30u?fl#|o1=rWgypO)(9)-UM&nqB3X8?=V~L`{Rzx6}%4IFL}ut z898~8YPRz%&F2P2!pI$_>~HWx3MWwC;$M>&cPw(cX0r)SW`m- z;r!WwKRxUzX$N9b5df`&lan?xF`!<3eSNTwh_cK|r;mkdC0b(=kN&c9Gg&3>Z$V6Ay8&txaX`mQA z{Wn#astP;)bf8yPx3|F2P``5fR$Hq78ZbK|X5x`1zT32Ec$kulL7M^IBp=_qlm`Qi^yv>gbS&L8GIiSJBaP z#wHvvcV+>p0ReY77JCBuLfr+%GT4a_Q(WQF0EsSdC8b}VUhpL*Cf?oO!~FgA*meB; z{5(C+W5|V6vP9D|GRkzy4%_4YPX^;QJHOT0Vd^#GMn*vilX@A^qs~lhUenalvS`3% zc&UuSJ#54&#K6EHC^!p;x7XL#1K1Q2(&bDwwAuvxoFV4trJ#US8<8ba_bE13r$lRZ z$SHemdt2!J4^#;W3G5zzvY6;-#W!!d#&e~uc+*Tw1#vAP)~|SYT$dW0FflQOV}~ty zO-)zqK*eW^-*<7nQj$r|G&4e z{`CbcFQ~mR=uFVqww#Pi%nQDszn95x!jQgf_V)H(F8fvGH)m&Omz!Ke(#6et|9dGNes{GVXXyyY60r1yU;G&YfsKdf z?d?5hUq3Q30#<$6&qR!Ke`||`fM9iXHE7)j6tkUONiJ?#7Jf+gf02=P&dv{Gfe+Hz zM*!GYS0{e|rXV7cK5v2O9~Bk++H~XN<;B9nDxRx;moFDb^~%i>R5qwa!0ezD`Nl-g z9&V3|3JT!O?hy(X0Q)gfk!u}No0^)Yr>Doq)#?q5_4KZ0bWLY~QG~B8);pjeBd4(I zODPZj`gO3gLt}br+~BD1=60GV6a8?S+WCp%Rc%ep$&{)D2P-R?M1VgO+LE1ZYG=2b z!eIy;Q*mENNXXRWq`8H~g5yPnVWZdavNAQ<(4YO#f960UKs0rjgN?1#>o9%!31Rqq zpiPY~E8^~3N}#orl(1*U+KY;|LotaA3=PjtPe-#QfJ0+c%{tiFupFulLBsjSGqU;D zyG7Y5+2Zi|`RCNsF8SiX$DjZ>w6kM@nFFPL`}Qrcm*8I!J($?oq3oCY8Y=JJxq5rA zwEGFc)*PzZ8$PW5fB|Q`+`a^dXSR9WUvFoNynekCVvKh=d=Q~#5&#>U1ct)cJdYpsSspZ^mqK?wKelFsS{Ahz<#a@i~C=F&`gPmib@=`?0mRkyAA+7 zBn0SO`!&&_0yA+#W8;)5VPW1S_%VP!Kz4{Du9w62HJ^c%cCfcE=Cgq!tZi&klas;L z$7ebM&-#>g@t#f-v%1WuEg<6>vs6HFyHiEO z!^2BUOP2==z{)N|_bUKT2?+=Q2aqZ3YW^b>QwwiiF@@b12skK?z5V?Hja|@|!$U)e zn}Azo2p$=~?a*fyBqSuz48SGic;Cv);iI;v<^W!_v9YOjF%-|{B8)87S115d=;Pz_ zrM5gGB0`=%uk&H~_wV0MOtq@9jvos;!2Do^4zgf{1uws#v}~40scmg-eM_6$@w2yA zZ9-8cL7rY~GdnePdw2I0X#dmw=@QMr`*YnIy!fzajKx87QzUjk&x zl9EXER3%!_vn}OPhsN8><8@(`Sdl*S8rc{!&&{D{H`gwJnmjx_F6X)c80X8z{BUgp z=CQbVU_d3Fj!@ln#$G^Ql$`|RTLl{LRLj+vcZ^$J=%?JVsXR(M>+1>^jxVv*==twX zGTbCTkLbzuwnx!FiYxfNxJX7pp#%f%H8e1gL%oC?^sz;li5JUh+;=J6J zMnQnw@7iAZXlP)t;~>~xWLu(DI@d08&mVtqn0Y|CJTn6vS4W=3M&pes1SncUryaQA zIH&#a0OwjJA;Apg?19w}J7G@#3jmwjYgirMvXk6iU!Pq2?``MHab=HrdwLc;;>K@2 z=na9m3n(lYd(-;-Vo*9dI-n9A_ZNot*?@9IKrRP%UN!TzHe-@;i$~;vy<*wS9uCRw z(b2LKwQ*)D%MY9bIvy2&V!vF`IJh$K3JJTR;o&O#vAOSk zcD@@pDJl;C=?*DjVJA_f!T&ND^nBsV?)th#pE~ZR;>t=LW@2@9b@(W#e+_10P;g?E z=(WF76<-MnHP+V)h>5i?HSg~3f(>nMZl3-OY#kF*(Yqu@vDP-rySFg4a>kXL{? zS(FI=OU!c~sVadlKoS2O9xC@$XJ=ExAT)K%$wR@x!BvVDASZ25p>(fL;eoS0k zT;A^P(Xp{xAB4ehw)pw!C5Ds>1(P%|(mtXlMn+%D%Gg4>|D5=|eEE`2_R|}|oC({g zs3=iUQAU-o1KVdMyZ+*VYrT0^*>NJ(D))M#_0Pq7yE(if(chf+?o;-O15&$C_{HvRrO4Cl0{E2Zn*8Hy` z-m%?g>?Unr`q!VJqXVGGxT^C2!eI<*6?|cENk>IR13*Gcbyld1^@$mT^c(Bp;nC#3ryf*6WF z#R1szF=%^&h5w$UjgDuM6HA{8qd;_0((kN@>d7wKItmJe9R&&df76Xw&eaNC$-8&& z_^f~Z_vzELpM3j@I%_iEfXAD;&e2Q}n`0PKjxM`l13Qp!aq*5$IWDRHMy&h$`)#bO zI1C$pE#D%4JOtzeUh?YA&ScHPL3w%kw{P@*#~2X7l(e)L)YNelD*pcd*4BmXm&g!u zAgk`xd#SzFbzlM>w39;SNk*;f&RgVMu@wp?Up{gpjX!4fo?cvJNd!KC1cQMPQxYHE zp@`E3nLc}ZkBt^WgHfU5(7l+HnE2zz58(d>&1;G)FA;xU+t)7+KvgndKO-fz>Bfa{ z=b$|AcRkg1p^EFbOrL|2p7K_+Qpc((ekr3^SW5bDf2jschr#|`@ZeW*8TRe6F9#qW1vseb4YvU+I09!SH86T=9 zFF$a;KUXq(rNyc(DcKGDKOW`lx6YMI=Stb)#>Xg-EuLgFjP^XN8i_3=l`AHXna#8<8T;bD2D*F4wQ!X54ANz~lfnA}5E&k{Fd6QfPvbBoseDE(%TU zKvZcd3iyU8>Ev#p7idJT3#ZM?&y>#CgSpWxBW(wP6p9Z7tv6}0f=RIE*38xRH&{0R zWk9sTpe1j;!6Eg--v|H}X*3wTMuVsuj|LSfH;WuGvPtpr`1trAj+fg3jV-_MK!Kp+ znT3vnLgm4}-AGaek>%#b#jUlpvNnKySrdK$#KV%T zP#lWnGpT8N8hQa@C=l&LlW+_T4wh6^9Ro6S(8BJ~(9{gP-XsmC$qIb9w&;&t748VQ zztJw!Nejr1ymRl`bQ{dg4!l~8P!+!peNB!I5uFg=zMh^2%l>awEe8qRXxyWKT3$=i z-~-;WjP#p7f&5=IH&tIQy$6!+`%oGFjT;TlV{c{~dL$;*B zfo%p;=xzZKlS;XM-BxcT(FU=mppx;+IY2dsma_K8${B(c#;xFlVQ+6QS2}`hG4!My z=??_z(+1X#F{wnS>=@o^v%^vbY@#)&9zaWt&hXj~Okb30Gk?|gAw6%RxN>DcA0>m_ z@50O=ZyG9UYR757DldN5+AH zkHAAVfH)Rp&X?QF^NtryuItONOE{P;U%yiL9pge8o=BJdpI++!#fSW%(+b7Uz0Gzx&P3|c`GOHM zD|2iMw~?cLTdYUg)7LWQ^<&cwWIUvpIJqRoh*Bu$%jipX zX6AvO9y)M1(eDa`G+O!jM$C8|hRnlO;>v83k@JTS+^NHW&^1c53N&i;*g=GxtHzu< zTvl4T;r7anCf+nfZ0OPz@;L8~(3p1=j9s^OlqO9A*6b+>`@rZZCksoi^~4w}X0RGF zu~_8@I27#tj{wY4fWm-TK&-)Q$K;P+dfX2A$<0nn8{BR7@7ygvKSVql4D=^YKvZyg z>K0KF@v`%Rb)`SGt#GCt&>8UXPcbmeYV@|8F?*p{BU~v=+oAQ#uazf1LX)bhxKf8J%FCytO+wY=+{Ziz5yG)&KBD%LH)No|KV-(o#{;w? zHwXk~1B^~r*AtM8b6kl`iiAq#>aib@wSS9^#RV%zj77=#@qZp73*}3jB@P~*s)~wE z*$g-ilU7$x04J4V&_Ok9TwLI(x(Ab(xQtugst`o?fz+%pu|}`Fy1M!WpYty^)LoWeBQKA3N%TBr)Ou+u=?73&kB{&q_V8@1O9V#*D3jAoD%O| zGP@gqbL!B4dgzM+l(%OU#5Ta@gB490(Up}N9xpit=^`r|8xMBAZYh+s=6QJX=g;5k z>wSHEnp#^G=?SOtN@n7Y`d&!1Gi@ib+=>iTQ4Y_2`)P^YGtg9EuX1A)9IhlCFy1;2 zqI9h$^Egj9$-f|;WKQxv$%ymck)N}(>||4ObIFW7x^Q6l-D0D^A1)zwk5M-t9|VF@ z{O@l8Hr#xrrBRXwKSf35nSY`B{%A-VON7b;!io8Ly_&gUsl^=${ZYUQrey;BsAQr^(yG;)J53!IXlZmD^AAVA9v`t|+H_C1QC z`45~8x)IRTUy{4D5fBr96RH6LB_AKr`JqSw?v(uu-GECK&${@tYl>u$r(GO@$kmGb zd0$^2lWNxIK}%qYQx5SL2)E3hO+z9Lv4Fyb4v_hLa$DoeQ8}?N%RgmSJl%rpFm@$BjB&)EoRYp7mq&A zM*^u<4^wg_j6V!5`pOva8Q9fRr&(G!F^<$24WjJmY=-IR10M2oVT#{bjVYwN4-!G+ zJn#tq#d0x>d&NfY=9VmH5rXvX|0s`;#v74^iE~%>#VHP1tUgte8uBuv2`kN^h*%|5 zx-n*--UMQ38~CaseIV9(6~_2D=n#j-=v@I5X?e`b4sU8-Y-}gu3 zdEgR8LS~6XQgu#sG7?MWBp<~Ix>Og0!?(v(>N2Psbu(w-VzdMpFja?zT26`3(M7X* z2m*86Bei=S+D>&u8GwjC$|`Mm7PqOJ>t{*|4jbu*s2;=d+Pom zxhQxayMzIsfL$rgZ8$liowWQDA3;*e~P?9f3;NV4}VdmZaIHuv4V zkMHAg@4s-*4>-p;pYeLXUeEPDvD%tS1h~|=5D0`oMfsU71cD_C{@#aTgP+%0+>#Io zBShudQ~iMVyYu&6kUCt8?fbKH5GS+JVq;_f0bgix~Zfxv6{@BXBg{d`(|2q@t(#SvI72&9p%mTefoIIC(V`HDUD4d-9JyP~G zgq{IzyOy^+AYrkP?Qg$8ArA>NS0c_3h_|{qp=$oQ04@k3B6?zZH5YL_VYaSKS5M9a zwMq8~4!-1o2fxeBZL|D}y{z_dVE#Fz3a?90R8&+NzPGma!@k~#mt0P{SeJ_|PK2&p zUsbY?s&v=0#gqr(x;avOv^F3Qy;^3tIo+zPdCL(E!SRc*$|@}_?da%8O-)_k#fkX3 z>UEcZI+FNZ|LXDaF>{J?<+yQEVM{Cb2}5~%*DY>cJx- zD&69L%{VhVyXw`VS{&$ODwO^E_isshqAo%HOd49+o#kck?TPBd#6)Rb%l!QO@bK`c zsHhdf)F~G~b#--LUtc}FRJ+Q&M-GWbn@nNhbf>4Mx9{Bfb6O;#pRI2A{Q2bET$N$v zagrwcXjhj_Z!(i9|72eu7dv}NMTIIW$u#npq?DArygUsJ&E{}Cu`YLdb#--NVIh>M z-#W{*$t3iA6izIT5WNqsIJ~)8G;!Iu8nb{5A|N1$=Jy+moSq9FDMCQF`Cd0jh{k7m}M42LQxjy#<6Z8WDp~;VRw!_w+_19S`XPK!R5Xl3 zE1#Bz#@W$LQ}g%M_&F&kvDCfWuiw0Zs-L)|7+1SFJE!*9LYX)?N}opHq-Yoo4-c=d zu9C!l-!g$a>ud{W_QUpcwQF8T$xyi|&JeL)5B8GS8SdT6A=DUM=qj>?g;@goTBNwjUnGi3$p8m+NElemKymsH>ZHgh)L=QYVyr{wyXe zOxAN?((JanvT}L4oHWl5#lqiFDc2`|@ZcdOev2tWRIPG+VscWAl_Wv2XmZMVF{nc* z`&V~&cK|hg(&S^MX!SgC1A)NB(3`8H^}*aaUP(NH9ra>eot{=%M#`eD^`kznvAjR+ z^2{mFC^_~~%heuRp*z^&vFn$yv9UBFE(IT+SB|?aw$Fdxh=Sn2)RdK>N)A(>tjcOg zHc>WFk&%?DvZ!DfY*5RTgOzn?+q~NF;_F?8mDSbk_D9;cZduZ&tNLDGT#1O1K7A5I z24?lyM#se9MSYuXcK3(z7gwh9JV%%##igY3-Y(Ed_`R13nyAVPceWggnQQTmscLV9 zDSp@^j_-c(;6WECu2f*dGiIjQDN|F^-i-CQINaJ7E`KY;XbI0)Hna<0b~$%~rRW()oM(b!pI&TU^U1t+RaCPzlBZ5^Pml{GYWEo5p8I{Wbpw7k8& zFV=G1OVqPRY*gu!VsPjK7^b8Z6KP|mFuxuR7F;v3u|?5xFo}p5?iC+>jkr6?(?vid z^1?2jmwbDyTo1jm3r3{L)x^c6Dn35GveH>!UtdvC@ztv*)MW)YIBPnbWC{ujW2aIg zjb5v&=N6*1FW#r6>A!fPtid&Uwo^YiGBN^Yn0o20#Vvm%B1;-}K4}rkM-rQ+TCB|a zv#*cpi5Dv@#il2bs>6+oi>t8I-`Cfy(P=#Uk%N=7vxI~MgLH7f1!ni^;-sgihh}gkcf8td%`ggmET=ZBbkCkcBe-JX3A z=Zk#`F2e$LY%qCCpFNe0jm?O>Q{c-!_B26gGa%u`DS?1QGvegA&Wxp*`^Gn1n3Au^iBTOz&=m4aDOk`o|{qk1g( zl-RoVG>XF9!_O}QKtvjHHOzNhb1`1ttB%gf~ohbAZe`!o2^b`*TZ z&-hl?*Gu)e%Z+w-cfHqtgYsiAn1yy&+rr`#mVXB|uJ!}mdwqQ^NxzrvkU!lHlgM_N zM3_xaPlF1}`S>(|3ySY1!Hoitlqq0Qk}>WmEFX>kkf%ej0_yI*hr##-maGlJ2bT{{ zgRd(LD)qS^Jl?*(xwq@FNK@(bA+M*YYdN`o*P$R z+?4B;Qun1TB=6q4cMqv8jv1MmaXOy;5=&0^((nn1i#{j$f?`EQ#fYQu*mEygZrmpx z^_!QMmt}+4;bHA-2xewx)O?!~A&G{D%gdL;Jnk$-GF#=98 zMu(HaN=n#~2d8M9$&WW~O+0i#!`qZnyu3Solba61Dfa0sUlpUQ|5$@QLD&2UeM6r; zZaK7D;BGd!p>9!H$R7;)#rCgnW(xuRgmHS|(TnX2LNID~dJe+E!T>p-<#^D=YMC1r z)rXb>_WwEH2*)+$ZXN-@OL4@#roV9LA#F` z7#P@7W367u%0lX{wEk>vzLeA}!C$a4vt6Ffb#FhLc7RU@nt&d2b8{owP}BS@NGV{??K@CVO?u z5@@+f!)l?C4LjA4gsEotMewQe8j~iMY*F`ozzuC}7pinlhOVyF<}~W=?^KJCpVH0R z{DyBn+>82F`Mkz$x{gg;d1mkk@#H0a&kpy;)4xTgNi+m!+^qM)l=iwq!8E zS2*nRTjP~|!eH7*of@BMwO?UO&T|%n_N2ZEOG&FvkAween4SggAsyU85_USjMd1a_ zeb6l*yTG&a+(b`v)GDJ;sOJdNhs|&B@NcNeY5!HIiIYrXtdodd@)W4egwiKoE4`ow z=q&`j>%teKJw-f~LhX&bWh0Np!=phN8NmCKo74&R^R2#GSr#mWbmGF`B^Coy8=K{~ zRF8g9M~+N9W4?d2;tddN!r0r{*;!8RTiagy?8JnF9v7R5q&%(0&BY_4S$E)QM z$Z`OSMT!aM7mGL7D`$`Y0tg4#aCHQ~d7qidK@v-b8)eF0Q&}nDz4oiJrkxt7^wJNL z6T;L3B^tSxS68E>qe=8q#zsb5Dayjs8V(NY^KJe>QUL5=prTSStHbopvFxfO-nK0K zx6)cVRcF8NLOQv>Ad&71Us|lXq7RmPh^T}jyRBPVTNCLdM0j}O>5)Kk?nm4jTHk2A z?X&%-D+Z8%<4Ha?RKfVgwr8VeaSdw68w+9+XMMVW5zO=M7FL@>C;?~yl zOpA~wuyY?bH-vF@(~0yy5;fNXbVx6KAnKQpxVUk#t`Sc& zEDjD|jJ!t*kWLVP)KQq9pC1bg3)B*5i9p!2*jucu)$iZG1K43@WnB{EPwfLTw^R!* zZS&*pT_XAoUme@*si~=rjSZmh8G{5TI5L3YDAOqdWC#|k#-byUHXfWJ>!D3fZf@xL zCdZb+U1>POg9nf8e^Gh|xo=S5SY`lTT70#h2cSwS2$PncoeE4u+VBqF#Fmz)S`Q;0E2e?if zYwI#|3da7chBQz;pbDQp#X6=Id5PW_2Diz}$=Tc6`#JEq!jvB_?z;uXA~z!g1ISIc zPx^G}R=BLJ-!2ISMP^P8H8pjGVI>|OUdQEOe@;#g!UPx_n5=APon3z!7a%KOgjTlO zc?_$RH8suaZ2K&(4>MC(lte^Cz-rZ(l{sFX9&B!IhJ=IwSqD_QGjMO@M1+KzYHINb z33m0<0InA59f)9#oDnsogg{b)518^dS5@tRaS5bQri@llQd$I54U7cvli+ssWllhm zNZHjLU0nzI``ZEzm?OSs0SN>AJy$;=wXoyiN*{H?S`0&oSP;g=#>OT%_)EgMEHJ(+ z9%A4!(6NAu@87=<{2zEmOUNN8NL)8pqxOW2t$ThRHkdDkj;;A}_!GeM$=+gz!yvuC zzyIOPaSuc2&c;RokslK=^e^zR*W19!f`Uc-{Q2`=--C|n`V$Pq$HKw_`3_066(~#Z z@82N@-4p7rsG;XJpdRnc<6PDDZiGM792EC=*`6< zk<6ubjlbp<7W;NVYZ{R7^o?2MY3{}aBTL5U* z*wE0>+&tRW5y^YfqYHFlb$NN=@AsR-xp{eJhK7cEdh&jCfPMwcTET!m^W9U$FlzV} zr2umT^gMtV71npHt*t;y)lTnZng{m1Hv4;Y<}jGgCm`VN?!MUOFObv|w45L+eSNxI zQc?omo64c336#(lR?u{v9RU6*;ALPiq&~Nl&xW>^l~@B{9V0aj2Ra9&DiGgp4m; z^|;l)6pr%_iA+Uy0Y?F1hD@KgmxecZOJ!x8hoazucWPbdhXyq;Dy%kPWG%vP&KPdIan!GaRPPoetJ&xhJXg9e7Iun^6L70Q zYHPh0OX;)q^Ya4?Agywpz`|hsCN?%($WApVNMhP)b!)3aoAdDKNF($kXq!y6v!Hoz zYS6@O{(Fe>@)TuO>~Kaf?2V1Sz(kwD|B8iPUjkxU7#n*QiunU5!5w*Q=;X9jYtz$v z(FszgkD|*ufS5$w7vAUR|6_GfL;#-q38D+10@gvKS?@AkH#Rn=C4KgNdfJGU1b`JE z2S-f)+HkhIoSaC z=V}V!Os>o^r?LeV10}?g3TpWuX>+UlhF(QORW;{{x4o_H#|8PVt3=&$ULKx{$&PE^ zKi}>I?cqU?*4=Rrz|tHX99&5S-j{O`kpRkxhsPPbj9H+7xCj7s!^6{$C6}2vVDDgO z2SoUEkP_rm?XO?IK!dG#{9RrKYrTu4fK0tGH_wkS=wKDe&CASm*h4}(n><8vt0$%6 zY(U!*p2ge?yFOvfXl_0M`a6kEf;~kU`g#Ln<^;hh$hX*V5Tv~Q)UnYRZz4Y}jf|YU z3HTiHcm-c%>D@`4%5lXBpgXfcbcyE&3B#i66iZC42|NmrWFLKtlAy$oRxI-L^__2Y zey*oC8cWLIK|ATxD3&;UVWQktmG%Q@>Q$<*6r_O3MiiW{%08R`}n9;Z$Si)pAypc6zjg!(YX&2?CZ1j$J7J|Ci;N#%g3E=_G4teoc@i!{WSTH zDm&v1Sf#N^x0k{>k&%(Wivx!@J~oE_T|fg73xKdh0xD0ingaQ&u)}X!0EUP{1?jMd zK(GL+es?QvBae?pDc^>JgJW;DnW*PLpyLv(wHzCMXKKD&Dn|oIt~5aKU1zJJsi_Hr z!IYHl8WmYnX5{3Yk7~)>zkM+0JxC`LLfdnYKqHDUs{et$P+x)H;?Gngyt`d<^wWZtv}N1RVU$$jAVh^Yu0Do!kL>QMb8S z_YN~{?c~%{o4t9g&L&I82me8v|L4EMq`)5GeI-tECV^S^dSa#&R2NR9$jQ$&cBV16 z3j!=lQT)h!ImcF1D2^9#aAX`Yv(L@ZF7gp%3YWBSxOIcB`eH{p68d1<`tysEY0b~3 z{7F5_UM-H6VV4*b7n(BiHdKZ9{0`G&FX4RXRhTxJTro6ZW1P>1a&3E?hYUCS*UgpL zKMbq=+}@tg2wm}D?AS6FurPyYDU~TuAS?3KdjCq{;o=_d@AnQ4_IR3Bj?Y+=;=E!( zOUb2lw5&8g{_icpU#ni-*0uWF2Icy^E>^_hDNky}9mVGr@+-@LIDr1alg*aiM%@b5vu)2B}XG=BLqXSHS3TZ8<0a==#e z(if<*mCv5bV6XQDZ$^vBw@*sL^&Wq|fmC{BeVz0+@SU~x1Jpu3_vL{3L?HNOWNy|5 za-+AbXnR1CTiL9cDqOIzvc28E>IJs(jz3T^jSUZbo}lK3hO{j#7F%1zSBM!qFx(cY zGgZAFInuB|FE1ggm#W1hbKYh`Qi;ow^hw6a?KmR2QvdduRzM&dJ+TPvzOJsWsi|b> zbuiv_BAT#Jo0B}gO;Mn{s7Rid94|^P=APxhn`mB#unQP!Z>4GbhiL1_AW<@{@GDWt7ARYwGCQWR>q60Amj;?K` zLL8~iUPRJsTX%UG93@w4`SmrIUP*H4bQuHl?0W-ln@qM9(+b~Ki-^ma+TfstFvH4K z4>1sxaHns9irOBfrwj@CY+V3l1I(2*rDBc#F~9Xgd>$^Ym(Ksz+T*{yNg0D~CBuCD zqmV$Y-)wpq|6~h^j2e}MKFa76Pqp#)=$6|A+0mB;{)Lspj%6O*^xLg`uNfPR`4AE3 z9AI0~uo9#OZ$El^d+Sw>6M9_ZpEbIlVQ5(?qYb^1@*t2~h=1P&=vzIEj>ch6rF!r{ zp<7VO@ur!dmIG@2{mp$4cLC-wcqo;%Y0<_UA;ZcVSruerZ2Zg77G={bf)Dm4cTMS& zl$y>Nm9WklpP%KBbwUe4plD4g0`vn|SMvDr#|^#S%LN4mV56cNB#d8zBwN4ZU|6_6_Ync$H+D2Eqy6OPGxs%S65eid-knae^t7Y@d~BnMRS`UFz&-A+|`D&yYqC_hW(a;aq;mW z!s1Kq16dE>EK?oZudm!?SR~D4z3zLsPj7wWz${=vT&%^ZiD@fz?=GcWpMSay=~Trs zEvTI94b6k5voz2%Vcgg0MLz9>_u%CRN=Ahbh5G1m69@W87&#!_;&2N~3&$XzP+Y=N z&$@iv7f7J6xp`A51k_iO(2}iM{+Fd0$Gh8d622Bi_jNbg+fQsS?v)O8J~Vn5c3;k7 zqg^D6xU=7p3oYR887+ z-j7_%eV-LSBsqWAVm^xBj!=#Y+9-9c`det(Qyqe{#wBcEg-JAOt<{|Ib?D7PF(7CJ z`|cICG);eiyBgxj!l6s0F0@@oj-}HBNwbx|w@~yc(8%`UMi-+@2Dx?DPsWh*NYB>( zB~PR|(FvhUw9zG4F{8OzwR(Ka90WN8% zg(OcnGrASk#nCF_V8WxsY2@GW@9R9Wupc*fOByFWsX-NX|M95|w0>Fyf! zeR0lpo$FlZPdNKwu(9pE_v?MXp7-PVyopeIE02#$i3@>1@D<+3s6!z4UV-2DvG0TX z8&!632!tM@AS12m{dEV4qf26SBeL(s%t9E)M1BAM{XVuF*{u+)ux}rVkx1>PQhC{T zgb>TRmtnlL^S z6E6c{b9Q$(OPbG`_t9XA*J@_kStlVRXNo9vrQhuDZ9rFPr7-wH!2<)5YEqZ45FS&a zn%yO`*uQn3Q`6DiPFrQKtga4b2oIgBE4!UNg>3!i`7dLPZ||Qs1Db} z4g01d&z?lA>e^-FXUH`bX=m`u`{1jn8zJ=eyj!aI25q+#!4MpRG$mi%*x1fA6V{ple?=(4L%Kmg(m)2MAtvV(PPS>LsM5%=a1vvTSyIc})+9tt5*5Ds~G z+O~A?yS*LW7)Afz>8WRfVVOFdmL^5HpsI==*4@q%-M{h>3+wXye6G=>I4>^`_9V5i z(8|=*^!p#=4I?Au__)Qit<^yK2W?+$Q&TKSNl9sGX_1>B_*%>f6B84H z!otN9w%2(Hy~q3e;o;$~E;-+0#?VB?4|aBT z*4C`ho=S4&l%2`!n`4&H*bS9X$V1PCot>DadhKF_*Pij~*Pn8g3~Tf`i6bbfr~(24 zFfcG&sOiMLeZbs|fUF zF=17&PP+kM%Hp5s?ymQltm=#~;PT35xmPyu9{MJCA|_ z2%%b9TAztEwX|?BKem0sni?NZZ944h>#MI9IXr4`TU)PkMEy$p@-3Uz7Ay#RQc-2) zm#gj7RU!MoJXTg~Wih(O#>SbMnO?hd@rwC7F9UL`L!s|AG)S&PO_&o#?B0U2B8|H6 zZ*I6J?sK^O_pk12H#b*T?XvM~?0b0I3iO0BGBT!(R#D@&9tH*(%FM5^MMXvNDFyzs z?~9C$<>KZ}dBdbVkG7A}FKjT}{e(rZu2EE6d@?HTf3C**B)w)PG&J;NYl@#d+Hs9m z(*JyOqS&bLH3Y+NbA3Iof5l#?si2@BH#fu>7O(RwBqT&Jzj9ANa`ugx*}s_@JNWv9 zwLoWQXKrq;NbG#0#|svgwZiPcO;2fX{Z+whfBg8b&9rC56``kJTHD-g%ouMiK#Axw zzC>Ln^;&RoadnODd;cmEci zaXqZ+_u}3#L387J1#~ZX)09JAnf?$&a6?k(uVED;mQl0GG$d&mOQW7T8Bc_Y#}?V1xoT`25|8h zipw3dn4X4JDk4u37FfbM0pXIe;uOBk*t{C+}!LhnH_C#n5Gv0#sV#S36`_b_rw;jz1Du(4DG>9 zvx5wTymAVm6$Se8|J;I`^Kx>6y|?FYZ|V?U-6K!%QnSy5x%t70hW80c6Anq7eZBU? z`OfXkga#`rgI%}AMV zU%!5hezVs-nt*x!9I$CTGuXCD5)j65?`~QkZ z-Yn7GdY^9VMaVEF^afs^Oc^zKKET2vpc0C1&RywGp&C+hcHYmD@B^D{!dCFavY@!w zPEoOMvebav_?NDM!Ha}mO?E9MC8c-oMnW*K0?(S}H+CX6E_MzW2aw3?(mK@30JOW2 zQKmBU*7kM@9DcFew3p0fG~)oXB~6TdIxA!U4#Q34Ye4}s+SbH(<2gwR=laH);Mp7g zvo}ADa&=jPxlNj{PNqz}Bwl^s(^FUf^5NdqzbNi6$bGM^)({Mf`3LSIv3tm-Iy
    9nnR@-Rn7$6fv9@W(WK&9`7v94m3#J=lLy{%dq*#@6~dK0dxi;oAv=j*p{0 zzn9u3?}>YCYNB5byj11lK_BTme^2vc`dg+};)XBo@~;C3pJ(RaitE`f((Z?w?K5Ma zl_V0Dx7e8XU*oI}Wd|+y5LIG1_g(I_v^X#RE{hPQ9!%ymP@by>i}m*HV+55WqLe)T z>(^g)Q(P8W<%rW6aGiSM1)BuD_ZRkBZY4!U8>SQsUvP7CTbTRe>#TZ`en39k>4k+F zvp(UsHT_86VRjJ3-MqfNO-)5*U}&hEE$DM-QRP_d?d$vHlSqQTzP`GKMi?}=ynL@f zC1bsxOZR86e&5iLDqK^&YG!uUYA9=GdwY9l2jQ`$jc9R`yZg{;{hh8Ds_W}(A8}OG zkYp416W??!jaX$>m5KjB=lZ%e_(G&n&!6CukdSa2HOg=nk63@kqcGDBjL{@Qp-?+J zJIBfcnPMs3O9vNMpw511vfEBxU!H9&DH<5QT%{$Te$2wm+*NsF@~1NBHV{3T!ewNO zu6*z?(6~QVAPR*e9je=GTvbo#YZH?r~!^?dGup672GHPl&I%GDh zWEd~jq;3_m{WizhHr;e}{{kWD9U@^?&S9Bt36i?r0((Juu2C@`eLSX$KM;W4DCFno zFE?sBU1-B-FnoJ6SLcZQR(NnyqkncgBz_bfue^ajofQ{1{3zd?yR@`ayJaB5dL-U5 z=~97`fL0&#}ILi^CAk7#tdcp*aY#*X?%M85#ef5Pr+_BjX*($)t!w zAwG0oR%9eD;)4SD=bgERu|)nlTC5o!nrm!pTia18n5vVr3VP47X|#e25j)cU`*)Uq zW*{kzjkYMUy(TLu*W~CZ^w#z&d_7(d{~_`*dh#TC@YsWL^S4*q#|O;DUTL!n?@P5P zUczb|H`LVC)t#vcu(AKvIv8+QLrs2p$KOn_6P%j;pAQW92Xg^idm{qgyiY|*`6J?f zNbAHzQWgg*Yfr~hN;KOBnUP}tVT>?p|KQ*~UmE%62bh@jv$s#Ova-7PKlCloOv(yn zA+AqnLA`~Q)_H7<=C7_=ZrXW!*KeGKl=^+2UU{?r`d-P@kjGSpz0}S5vlBfI(j#y2 zKR+?B9>=krJT&|&>_mIL^-VS^%eiiYGxFl%0x;2&2pNlNszw(lv%naUpd0b)Vq*hq zeSOO7t*}RAahWAXO_P%u00)9DXXn>7Dl01+X>KXoe^yruXy`X^h>dbc5>qn;Zv@Hr zUVa5aa?Eylx|7Fs8VX$m?+?7aocHtdtJ39=%%&qHO^%D(9~9dI61xd%Wn3Ld&bRQs z)qxDF!LJ+3%gZY(CyU{9o&n=+hR4!hvwb(&4j;BIw0+Vk(S84Yq(AYBvI5XoI02oy ziLk?rfYl&1Xt7>iXDctqOJ-*D5kjdbrX)qb_b6ES4jmzED(DJl&o)R}_o;AfMhT4>bu&9PLn|2U{48uiTjQLg$Ok>Yuc{3EPM) z|J&=cW5J;PHmpz^U;o6)V({1W`a=TA^VYd5%IT1cqqVbo*?e{Q8Cl=I(Tyrqv-mkA z66mnKBM}+-jUb1}-3r;iWA*$I0l426*0}fs`CP+DuE@+p<(;`PL?(Gb|uUt`mS6A1@SYd`Bc77>nL+IW$pkO66 ztCN$HfaT8SgIY>U2fX_pBeBgTDi_{VoxfRsfEfSpWHrMH7=|sKa$^TOJN_q1^ZrLF zYHGuy`AVu;;(CDIr>EiSZ~)MtISpk=GyyD5Onic%TA_@wT>KrzjW`|!x~^|v@Dx`T zP_MSOwuJ@LwQZptRSugb`^3?ZRCav4%QgCBaPVz|VIc4pKtSK+$z5OX1qB^mQMxSm z#OJ;azquF+8tm^UiIBk!gL0)zO-y8hN&x+e00(D7W5mu59IpK{{>{w|a5VC<)bNs$ z#5YWoF~Ex)?IM9aYOtFsi;0f@&ZXyPu^WHNB!Bb#HTX=qPp_0neHZ(-g&J!cI^zR=r zAt8_Zx>}lY!Bk32J9)6)TV>@rAUja#{n#bGv_Df*UNhA;V}+_g`wJn)WU|TNcrmfD zfbV%KvCIR^M8(8ll!AR@V`J0P7l7k?D!?IPDEJE+8ZOqdg9?m27LG37y?X~vpSSMp zbKrZ3r6@1&0pbkxf|d0z!p}=jk3y7}kB?76;u<_T=GWOG#Yf%>Rz6QI=AUarO-+rk z6Y6^bjGWi3CywDxh$v>+lQscMAJZ<2OGvc0wXF)gOJ zTwGk>a5yk8wl$4Ef9@BTHy6R-8;zYcc2f^AF*)=qL({*crA?2FNH^~`*-uvjb&`!D zy*wzlUr+5_>339tQIUf$kZgB#fMMMXuaspLi3e!vUkkT6%* z)~>Fs7}Yz6w3#YriBI?T_9lM70tOH8OpKh{&D90C%ECfIUESTYKM2M_)jbZ^(AQVY zCMP8&B_etU?4`bb-=gz<3=D?;>&8S4b#)+3KtNTp&u8q)ISuQb=bHnXy!Jf3yjaSO zfXn<@ zQD+p{22ygA2vd6R`40pFh;rKYZ1T`rlnkS7vN{|{AuTO!0ms(jA_H;6dU=bmu&~!` z#CF0nDpPnlaK@3wu948(94|R6yc-0e0^gn z1tYJ3@Q#}VUdVEgR!wj3@9zVURNu0(vwOkDRM2D-ZBG&IO-!JjQHw?E$HIa8MdLStv=t0Yk1||yl;p(|T;CK&x#Z`!kz==U29r-?*^W;YIY>>K{nG-UcP%=vXU>2E$EFWd z#$)fT6h4!mAB$hOmxsp(zO?dn@{7_NvR0sI%TD?LQq5-@bF(wHdt(MH$5>oNsD0m4 znbZQ0?o@(HMHJN;7#IjlIQn9Jq<`(m$jE4;1;e62D%th@`}a^N6tq1LQWY4gr>7@4 zIM}J>I(cTT%4%p3?cQ; zzI^#ofKtWN^Aw~;G0O)dZ0=C#?>L6n7B9hxjWwEElheK|)t((4<@4S*wZ)C<1d3JQ zPA?~9i9SEqbxFi{1{@~n#G0C#b#9>kpWw=Zf`e^^aGb5E4-O1CI5?=Ns_uC@`uh5U z6wT3*OYk{2_fT)I&FFW9A<4^aP-wkYbRc5`c?IZ`a0|d~&*Y+q?eLie1q8hP{4{vC z*Vg3eAUzx)$H~c|my|@E_vFFur?xMOoOE=cdi(qPk#loExkRaBL32*!HOKxqOx4{R z2aGf=3_#i1=4PKMkGHt-n>RiG5j~5;w-!kM_KlH%02V>Qa(Lly`!ahdTg2sCc)0w# zckvMfEYiuB_H6wtheJ}g{(u(J($eDNm2Ux(sn~Sw$mc-rp787ml_z@5TqB@|Iu z#?Ahs2nyKf_;|I;QWp?UfPOlfn*LV@-A;&;Js$xis|iNzr_9a9zbfts;2D`yYHw! zsl(lSq?94YEi4tk5QsGpDIBlAi;#g5%GB+tWAH#uuAow%+UT*N#S{8JPrrZv{`2S0 zOfj!YU5=tH35ohx{ZgUIEU;5SbgB0f1A?~>>2mq%KatVB#G7X6KHp}_BS0BbT)YLi zGCe&#mQE`6E^F>|`HBk2)hrxkf5x-=TqL!N4&!F3z*Wr)jGqyY> zKrf%Vgg|&mTP;McPs)RycL$GJ3xIsMYpd7MMu3tuYGq?Xf%Ab)~Pd?~fbk+~mY)Ry?97p(e$0LXa#`&K#eoSd`g*sn=9bjAS z@p1r@2A{A6Tb}~&^8xKAhTwZV@brHN-XOncP5^Ai>(0}TH}HI~LmfjdejUe|+KPu< znfVBG4$2s2R#t3^2lw!T;Zv>4_)LcnVW@oW>lYL1D#i>6TC5k+aLs=rZ<&SEYiw)u zLEms3QKN(e!wFm$M7WX^f%exM?mpuFl`~@f-~2aqzBJ`h?aOeft3?17%-A3CbolOF zv}hOSH+w4OjW97WVPay=HF=eT{Mzdw64Kg+LC#R^w)PgUUGic%0XQ$PMLh615AQ`Q zBrs=+xO}9}k=e7K7CEFA6BYFVye1MG1c*m5pP2nUB+)Uj4a~EvtLx_0 zme|XeJ(N$|e~yojX9wM+(fm18MTl6O)sD7JP?3y(z}N8yyiOc9SKdF8?q^I}`CW zbb9WnMl3CU#&4`Z1sJEDmx#tj-}T|#&qD9T?I0q-w6soO?RqVWCN}-fb}4-OHi1pl ztFZ+UWk0A^&N&?B1bdb$dl$$Jh{quQBOC&aK%T!f$GRJ_vY7PEJnXL$0qyafLD@9Ovr2<^$?L zXJKLKMm1psJG2e6|8KGR|Nq|a_s!n3STzo)aqYY%1C$XN?t$&Foiv~V{ee8Cy5H(Z zjx7;3EP%YlTc$t9e##)TezP{~X9fBBlo^`mF(RgaHXV;m#6H>ST(Mh~2?~rB*1h67 z(h9lQL29h?V&f2gp?4=?5OfvD!p@Xu#m!X@l{?-{=EXZYIRVW;dG+>&3%J(`T|*NS zRYFWxkvJ2N@|1F};6jG+Z&AitL(eS^vK~Bm0KC@n+S-yx*@Ue_1MHXS+!a?JEv+O&F8Rlphy9iE7m|+`==@@haWe+G zT;`%3*3I}>S*<~O2B7=-^XKCnXuapsgkaFwT?394P0q{6%*;$rpHoxg0V4x#-c+5za>dxw;@2rD()vDAhL5(Mie94gy;I`h+KT3DEDPL7@Vr zn$zLQ$%<=3Qc{vHXkuUxR_}M_Xl?zYyxhsih{hE=&crXNT){!xLPRA)h*MNFEm|O7 z-DbCeD~-0>g%&1%r_UJ!v}Ghf=&#OQUT-!>LAIO93MAq@+{q&sMIb-);dsa&ej`pCG;jL=F5$~$yW!7 z#EL7;f+G2$UH75jN!iGEg#F}RS*ihz#cQ_V&dVzt> z(A-qu=Kvvi3#ZQym)y!MZRPesRj{m?w z^&@>xQ7Y}9<8q_;HacQr=p+Kjl%)XQ8LtJ^Ls<8gCl{>ynL$8YUhvgSvA+tgY3MMB z|9J2+c+KU&b&cxWq|{$-Z~;-8T{&WPdD#LbL2G38@DmXwCHkdK|nBVUkk=ZtWWM53kL8RhZiuEC@VMjd`nOZ%Ewy=)UaiRE|qZl6_n(j zs@XK5ctmGqcYitnsY^am(z_^y zT%DE?3efvo?$JP6nG167wdJyVKxjA036vm^k|(VX8vA0drXtitp>{-Ox1PHU82!6J zzZQgHg*=34n4{tYD9n*%t7Jmu9mBCeA|+%}BCH_XI(2uz;C}87a$sU9QmuOlqCNVu z18>rUQGxy#zH27{#YIjLWZ6qCKA{w0e({h8YMruc_X+3TFB#!toZO~Hf}q?1{qGsy zq}M4z14jayPdjLKr6CyMpJtCH4;hiSEQBu$F5~1fYOr#HHcq*8rp6ptphwgd?Gg~Q zHYA+!*bf<=Cy^gLa$=@bz(f+h;cHsRF{(AVtYLghkgND&j|~$0o4s&&St_mUz$OzX zMpw*zY>W05pKU5Y;(S&hXiTMgQmM@u7oR%z#1~H_de|_5#EmFD?{cBIX*mB6locbl zw%epv&r&=Vr?pi)tbqLw>fnoGNrESmg0J8*eCwgIE7ZEyp?2LWW8(fW%l&gUs_uR2 z85_@|rPHFZ#wK~D`?-5IoCcFC;lp}VC8WWU@4xQyc^j5bwIkXIa`}*S|MB_l;Xs7i zJm<)PT78wp2w#cg#T$7Q0C zpFBaCFm!L@@892xXhfFla+;6*GL;-8NJ=STnkUNY`h;dLsTBt-=$*^($8%8vq@Pb# z_YtRYD67W9u7iUE#=uKA7nj}3GqeUzPo1X)&ZZf4SYu=3olmmD!ou7s1ApI>$p+x&{>(8*5JD(C9LETOX$Ig34Q^AA5~THG_8b8wZ=>H^R%?I zk!01}VQ{)fr*l3cWd!uQIQi83SS(YjH$-zM*?7}3wy_t3|_zP?dj3U8OfF$$n?aXAAOJ+N5KxE?oZ^ zg)wbM;!~~Im>5AJ!KY6hrr5jV!Ma#bRx@c%<$jRZ>(B)Trz{SO#a`d`}``5+){Q1)hH9a;~IIy(2GdUV;7@DmAxiz_Qre%p#=4S%hkX*&sc zNc1TxDr(CY#NG@A4ZjysKV%bjjXfF36tx+y-scqf&L5*F$B1H%e^ZvN{p?%ss886% zy)<<;%BZ<_`JWgwhA1QC?X%HO+1T2!#|xeqa=a=p)NB5Hs$0|;9wsIxiPrlpieIl( z^V;Jcl9OA%!*|?3q5J~=i7_#KG&&~7JLn`}Tw7mHYv zh7KP zcwnV0TwGjdXJ>C7gYUXGF3!vtJfqNcQmirh$3X7RIUf{C~ z#P7q^fa?nh2^}8{6XFH``0+z7{BBZ83Mf8K0_m}_u^=HqxV0Tko7}&DAJ!s`jgPtY z;ox7-;MDPkaL+ksD{kI@yp$bZq!vgiqiVf(=97~tABf8(*g85QJI@kHU0t$J^|a-poPhM?+0%iYS35M3t`vg#d=o@fB%+ko)Qnl$x7?PmelqnQ!uYY7pN4?q9AKS)|`Sk>ie92^pIvOT9~W>!AsxFyVQW@e_R zt6S%?1iiXEo1ZuSB%3?-#@N_6GBWZmoydOk3U=61Yf#HIj_U31?rvrK)j3?R2#G{e z5Ggn~IA~}L)vp9JzniOeG10GK_JLMu{-k`u8`tOL=xAMWe72Dz>@gRd-Da*9C!iLG zw_vBbhqvDoMb&LND?@$gw%-PTxVR--&mY5nTUf zj}Zj>AY9WkGMeqra^=F+_X5q$8PMaU^|wxYs4U(&I}6q&goTB@ohYQ?v{u-a_;+B5 zf@BLh(|Tl4W<8}!y@ZyFjB`qoze{=E>Om^u_V1@J8sSAhvVlI?U2-aOb9`X#f6%kq z7Z)YZY{SoK`2ElR-)?>4KRDk>^OMMVeg8%XP{W!>Em zH*x}Fp;H4q?f$3xRwWKYE}+U{n)lk&G&Of?7PtBb28icUWqOhdVib;cRv$NxY7R^WdrTE@o4866iv0YN5A4(FkfXJ1%ptfEa&3mi@)Mb{C#yc68NGB z4nfxvk=RFj%aZJ6#1LC*f@5cB6KFva+&_Yq70A>gtZqotNcHV}F>7lE3@%{PnUT zC{E*Bo|wl|Ob=s_4Rihh0Rb3?c;%m_9tNOV`Rp#{ibWJE)&2NEy;dKw%t)%QuTRaX z*2(*Bros%7*?2TN7Iv1Z^ioeRQ6|tIKg;PwyYRw#Y%8@uGy^5&@g@pWgH%Q(v2-e? zS+84zI2n|gu#k`AEaBa(o2ajF@I+Kj7(Qqtrs5VrHw}yv#Vs2rRiS5>hvYPy_#+&Gk5q<){^-lA_5|(m?p(6P%3ud-VYdLQKk zoWslOz5aH6LLls+yb=&1AfjzTC&f8nGu4;oTZ+z$Lh= z?Lq6=AQE`xkNESiCyLRnK20zZG@oYgodji8{U48--CXUV8m9++jARqzRg#i#;AJ@0@_1W6lc`aNEhFO6l5R>jk{}Ux5$`JlQCHO@q;G03di{qVq zp5vd^2aO&Z0iYrI`T2Q>(B*1VQ&J{>{=ARKa0LN#2p|ok8-ADGI;M`T6fCvAynG0g zNq^4J3$^{9D~}SK7n7rEXUzT9lMJ{m+7_p$?KzX%uFg?kzkanFNX`;*%WG!!vfmwk?;4$RotcvI zG+tSsJxLc{R$40Vd$`6?*+NcEK3B|!*J%8FBG=-B!%s1=;_PH-at8Ov5`4BE1;%cUS9H&Ol)~_b8~|_10E)z zMSSNuk!YFJHGt#*OrNl_{t>Cre&!E@6=~&2+4M$(I!hyHVtcu@xvVUjmWGBVEj#-Pd`R8$PnwK|)Y8>;Z!BL4eRerNKaZNJoLE(g zr4xNO`<*f1^kby+|3bDsv3quQ_TRsMK^wu75fBzmNlqT$^F3-P%g8X1mk$jOCx~5j zc6Kf(DCngvgUWMKaKlZ3kux&#>({eq z&%Ro87lPcdpDfn7efu_8UJADv&>wn5ny`~4pNa8t0Br`)x_eTmFImZTG@5_*^b`~p z0=jXmo$u=E0*RSdSXhXnya_QfvhSW-x=?j{dwV4%f}^Ir!}Wp0#6&6{^UIX>OFM9h zn3$M_dRHEHc3SBGe<-vmC&%LL+pT0Sll1iT&7P2u5O6_j8=FSc-JuL&P_u@%Y5O-( zp*I5I^kDVOKfuUnZmQIPl>0L;3805IAgQ({ib_jL00V4#?@CHaK2=X0ou5xnNdbLL zlrl=t_t5s~)2ANE^e=3Gt^G~}nHeX2^#Qca-rnBR(+`hoKhjcD|5`mzRZ~l2>F(-c zW@ZMEq^73kD`9SF`Iy!B<%YYeY9y$ZyGzY!EEWN0>k3hnAgx>htAXv9tVP`jxUsR$ z?QL0B@*W^$cV_x}dSLsV1W8FrJgpdAC{!%2?*cQS1yZ4W_6bRMGLJ=eRu;C4n1_Oj z%5}L#l$+aS{F{nv%Rw(-AaD~FemjCt1W;rC$8!#<1urBeVbf(s*Vw&4(4gc(rbS6{ zsk^`TF$PMov#XZBwg6mIU0sdR-2c!5h#9W0ifveK56p3y=U`@*PV>)L^V-MMfvT!1K|w)G8b3d@rlw|~zu(c(u~@Ng((&jFy^)xZu!%5(Q+|Wn3yhDC z?=Cjhcx-5-r6!xZKMB_ZIvzly-|_Z+LcAM@2Sf#80C0RP zqqLM&7hcotZP0|4n|)jdlDUqKj@%nB|8$0ca)j^Pj%5o9C!u*^*Z(CbCIG^+ zt^;OsEQQCyKKq)Il2TDg$tRF>dv8w~ig`oV2SOMK9QAFyDVF<^#Z!*(?Z59usKek> zj)+0_q{z8%84-4H3pd2QMlX%Osoj>9-*mTtY)Lb)mSBjRD<)<2eT;B-a&mhAo>$UP zU;j&de5XX60`q;Co4LL|YrJxC11a#INJ*DkeEFwX@M2f-HLmM_{NSB=7oau_oQ}g0eVlAbm92r9aIwL=Qj@luyk&$_A6Cl$WP}4E6C4Ds)Q|=VeguYci{sl}m z5byY~SF``=vmc?+4~RZtX8NMdbG0{-$o@-)$G>s;QizF(ACQwXv#@N{ys^h9Dk|dR z;rXM7gfUV%pksol7Wdvx1yCZJ+wkFld1{hUh73PTp{cpqXF<`x!Qp8QcTk%l|G^OT zMTMA{7-(uhV|0cPXnZ^9eC{bL+x6Y5n*y4fn>*2LQ}&2~E%-5zieY!@RpY?(V~+oo ziT|^>UmZAc=G*HXp-7}-k}6p9)Xrpa@iQQXL62F5KK_jrO##)>#KZ(N(B`c4?CfPw zLNYSc;(iYdc(}W#3%niI(~|VMy7WcP5!4!+>FDqUTz-HG0l^;6qC`81uLnG}me$rS z>SHw8#v5XL9~=a%JVr*>rj0QIoWa$DOsKUz3Xs}5fW5J?F$snd;9_mhRW>|s#?%LG9r8(kFV?=IK0k? z8F>qCVSB!|w6IW-EfF{nAk_h{kv)04hBwE?a{guMIN z;d*8!9YW&Xur0tUD5vEs_9S}o!|z!#rFi(}jziz55y-UtkDBO@d5as5df#L^GI6!`TnS(7>_I2;@t z&!7KZ7QYn~V`p#Q+tJKv3BSGDkh)JEjiw<8 z9l8+Xa_?V^Y!LkAM8oCfWnxNB5%;zK)Y({6suz6VIMkugfZUuO>v&cbve1W4T0of* z6ED;{QH6KsW-Rjuq2AUSKR}6sZfa_p`A{6v@%64HT_4<__4X~C3}+cMJtw7F(L`fY z(@oh25iwh3BNF=n@8haS&6#ZQ<283t88Ygyv^VM6%<;B>OA!d*5KlP!06#pIOiiQ6htt8 ze_cJj0^R2-32MolzWY(uhw`xAXc~lpwWuwNIVAN_2S7+Rl|!BMDPXvmw+F!DtVErr zIfEYqrZD16-l7a)3c}$nO(iG9dy%G|tNp>(w+h3%8m&>V`*WOLQ74uEkH|1ES>7El zHT!sp-PDj5cWzNbJSsoJA1P0gGK8pEARe3PX_ztTDLgxpDSr)?HuNs<%@ab|(kWdN z0ik=4jzt2|_jD-(>k0}A{!-U-4mnd>o@=gS9@TU2f*)V)hfBBYcM-Fy=>An5dWn5X z7|gJptXZU{tV~Eq*Z?{<@UK@qn{a|c2zX`R01JRR42Bs~0^#cI?vA82p2Ve2WYZ7} zxD=U^iRhfGemf}&{&2Y5>gPsx4Lba!qaZ~WL;~2AUb23t`+ostgGRNnwl)czBTrB5 zC+uNXAXTNWFZopxMkLUKfPUTREZPIGU#O`Ysl1V197voe zsk)?Kt?lmw5C>dlO<$0|G7(6mDvT^b9-uw&$VW!*837171dw1-?^3V}Stu?wgwT}tq6;-=R(L$m)M zUb7F50~y^yt?wUJV}*$CCu~YLFtOHS5t;XOndN5#ctFA8l>ftUDMJYqihj z&N&;EQb9#5lX-FG8Y7~%N7-PHd_R0x+t|1nxrK5Gjd_rqFiGVw($NiWpbK>2rra}c z1nBPe4Gn1ksg~4VQo>%K8$n0-EyX785=Px2jiGB5;7*BJZMeNK+Gne+JqmOIAZ|5P z)lC(DshlhIgm0<^NH=E&1_lJukRbFCO;8|&QuC}%yvZQFp$U6SM=UgHWlRT{g_Dm;` zv4?21N&;qo->?-b_^^qPDfltqbMRrMS+ji^U9X!zuu<_*0%|Mfw5R9ihi8}l{i-3G z3WQ#so{McTaRC94^7R!JcSZ4(D_ajwtRNZkpC*963N#^HS+8^|IUA*mUKQacBiy{5 z=JdzCF>dQqXegby_u%aLqeqXx-GWis2C%t$_LNu1z+4Re!wI7cFz}l$+>p~d6%vM- z=Lqz>Yl%z{1cWatYE(KE048Ff@Bwvtxmh5~2SyFR@dMVw4P6QP%gb4E3hmVCd&BwSrR-%GByTXi=kT z89Mq+C6P>aCGZ;iiI}QvAwe-;)C)NYYTVX$P*iiZNBN&oL|$I{C#ebsUR^%dE7UHU zdjrWsW(v~kr97F`Z}Nt|48hFv1^O*RWXJ#ruB=$(kC`@m$CR|axNqF62@3>s67VQV zx>G-ubSiL$Spo9{sC%&4KybcDBO!e`Ib=RjbTX;m*T6-xRcmvV{wginiuz%_=kMQ{ zsxP$B+KeRJo?c?~^cAymX7vgQtTJQ`4790VR1030dU<&8^TlOkWPrCpXb0s=7`lGr zac5|@`ZWZC``^v~0`RJXX_tV&_qlC=$zow?G8gS$e??RhZelGa=7~xIdt_SYAl-H( z(bebAWF_0qZ{JpZO=!%?Me>3;tJeci@Cp?|cl+Cn`I*ye2umopSyQOhEZ`s;8yitN z&6&}>;h2!%VDo@8JD|=7b`Lfk?5hj`#`Co7--=zmQAx?OxSB9nt1g^`hzLpBJqeR7 z&^5BKi2L8OUhK=nPOv$Qrxbw3#31ZKCtF-zu2KGaVjT(k3E)XS65@OJa)ezOQU;O+ z*4dN#FE7;fzk(K+tNkAtI}guFdtiH~?*{{?=`x@cz?@zlOD>Uyrfb{|B^XhapPExT_F!cPX;zgt` zoRf#ADEkv-RL`Vx`RfJXB7Gi_m4n7h9?n$Pm^#+WGvBWXgM;Pfk7)~s=jG>9aT>l{ z;t;|PdKj>(03TjED)m|GjoJD2>*J7ku!6L-wBo>(=U%DCr3^CAi|E@Y@ZybP!r;y0 z-McbmU>4oba0>JR(Wkt|#eTbY< z5o5m0=AB=H5E6BzxKNrrD?-6d0v}J|sUQwn`g^#BLvP6VsdK%tzjn?$-@p<*S!MSm z=L2s2NrmzS-IvWl5j=`kw=*uunG79u#)h}^vT;qvjWG#4%(cMeR2u5r-R<}qw1A#I zw8mbq64FlvD%+^fhm5vk{8FMuSI_y;XR)(?7$8`VAi4YJtykUg*@$SFH-p`x@U(6+ z=>GVDT-4Dg{-ZB5*K)p^bSb~+6Iu_|ixg??U40m-CzZy_LqDQF$X+zH&+!{8H zMigpMBcJ)Oj1_ylzwhLV*3DTxLbE1=aoQkjd_^+vGa;<6 zcn)bs*ZW)&65G4}H!z&DP$U}f)}KG&4Iw?>ai5Yx2&6~pU+6$E?;B3$oM13?0HZt) eXy=pHmw}C36MLVglO1omyNZIQe1)uK@c#jTFAEO< literal 0 HcmV?d00001 diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png b/tests/snapshots/v3/test_footswitch_state_view/test_progress_border_snapshots_at_integer_beat_syncs/transport-beat-3-on.png new file mode 100644 index 0000000000000000000000000000000000000000..58dc7677d240771049ad0e3eec4d379c8ce3fcd6 GIT binary patch literal 9173 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 literal 10077 zcmd6Ng;!N!*XLCf5NQ-C0hJQzZlt@rLqh3}ORIo@G)T9!f{4VG77>vyX;8Ym^V_~N zYrQk`7tC1;*M;S|=RD8Z`&avfsVd9hV3A=V5C|MOSxI#S;@VUAyp4Gs{=HOT6ha`V z5pt4ZnqKJ}Q@0F>bS@FwyzxYsn74k?QfCKJhicj0#lvcQr#<1&R#sKS5H2%p<5?5> z+kV8h^zhSgiEcFV3*-2KW2sa{HKW0Y(yBni++1p2n%7SAnI{3JUn>U@55gqlw$J>J zw>u`cET=ouy!RG`v9BR0`Qy0!46x5;tm}2e(co^9r@B~6t^D&_@9TZ)uq)lD8y z+`oUZQkZ3BVbPV!t#GAn_pBA`>DD*4u8xk5wzjs8jI)h7-@}W4f<7BO~=B5vf^X1VbATIT)TEHGc&WsVan#ki;5VcML3q0lG4%? zZYFVc?K`U)tMPUw`=Up}c~Et&Pp;c{&P(x_R^F@bBLouB_s+`Ra1{1KHWsuVY?# zCy!Sv1YGWA5eO7;68-z~&CN}|D54wASC<#7tE-z1&fH{5aM;^_OiD`1%A$&zqokzt z%M3ypI+&W8`uSaC2zm;zHn=X~M$H8U1;Kx1O#C&P;pv&iX8ZetjYEI6Do;%HzMmK-UCg-Z=SM@)Z5Fdk^?>yJ-i<>%*% z=k&F<=GE6vj%Fk!B)p~Ik304K`|ajp?u`E#^8+zYKZYPND%H|qT3Xs6YkK;^2NEHp z6BC|g-^uu#ObrYs;Pxyy^jt9%_zWJ#7Bx0zdbhA9b*9i3O6L(ow6UFTe8YL5lapdi#?E42p7_zx(cw~^L6lDE@N~U5Cj$d! z`;I}4Lw;f5)%jkpkpm`FCT*g8Vp3AOdBq*9;GTyssi7Ka2*b2Xhl|%YCajxWP?Ie! zVpjs!gR))Ue%+goS6Q8(#uGXIbAGZHVt3mpO{u zv2P?F^3+Xyi|)3%9W0Jke)8CjwRElf@IuA`xO-w(kieaWPy z)b9GBxw&~$JNvqc*05{DT5>Xp)tCbQ_r>lsPEJl7JnV#K1c}T??U_cuiQ!>xK0e(- z3aiSo2M-=Rj9ot5n8=vOcdK?=8MymI`+Y%yI?A*29kX?BCO0FS6CO6xPk4Mqv!$Ip zgzqGni0|FIhj(AVZKz0%Fzj{7Ggl|4Y@5u8h=?}xilDmL9WPCVfoPFahDVQDM`NC% zqZ7ZcsMr~=vi(W+rp-J>adBZ`VRlx0!JtIxxsp=kU)~L>fb*RR$EHZL(I1iII89rY z=JgYJ2rtcS8C*b_|L16SnV+AJxjJaY6Ww=rvQtuSIze7-bF^j(`z$|?r#b23?A9g>L0(U9RvC(XCy1KeLlAISRUx1H~Pe33fIGEX_vA(DX^FT-SI@%mV;;;I8 zA>=t_rT$m9$9p46OvLSNZ3~Nw1Fl$tb~BB_w6s#inz{bUSHez9XW@le{-^UD@!Z_p zjEN=CFv7yZjEv!^j{ZEEgPEH7r6tZ0Pd7KiYP+!%PHRR+#_{oSeFFo>nZ^eASzdlF z+;TaXb?J6_?j0uiFsXBEx}nZ>sV6Wn5IUL-?~B`t`i1i?Ow7?rYZX1c6zRO2nJzas zH$_?5(JwCs^A(cdE9YHM`X3$a?7BKT&vrXmv-}SAGGa(5XDA&9(lat4vVb5jjgl^5V$5Z3sIH>N}wiQ3>J{q+AFJ=lvh0N9!)T9 zj2{pr<$LmEtuIF+gQ&=mB|0L4lNqgPe?UQ#_uS|dE?C@nJK2e1rq0uGDe*2=o8VUc zGcR;XZW~Pr35g{Q;5D<0zr9^u7SqTBZS+j`XCa!58xHho_(## zupKj&oM$ETC`)TmD79C78f4_;@$vCe4`Y*(MotgbQYLyb z1e2zJO-vZ%tM^Bwrlnc;q`z=<+}hl11VqW0;TNW5lZ(3{_Q9a0r@dW<8ea(NH;ude|vj72n_@HJ684%Il0ftt|ijoqqgAlKI**L(!w`Ro}P{l4p!2P zj~~lX;{%{E8B{BN)E5evd}fFIv*SfTKu~aSWu99XB5ZK zA6>2PdcN+3b>$;p60BV0+1lk+G8CQ{Ya;iT)sX6icw*Mp)_I4-rtcLBt8&pQu@Zr`>Z~A1Vb##yn@B{#vv!}v8IQwW&W88qODl1dYQB^fL zf?t!@yu1(ekFHciPTptAu3(Q*5)tiT_9&ag%I7y+CiDJ&pYZ$1;}%8ww(sq-K)CuJ z-(|i>f1j&{#!KEaF_Cu3bO$TNXVY%LY4Nm;`YTZyNDeNEX-DBD$`$pm0~B6CS+s+-E7-UGL_dnQZi{Lq)rv>@I|a zpd&eGXufYRHA5Gi($MOvqW@l5`no4k4Z9tC$wVA+^ZK>l4XjP)arzq8&24&>R_ydk zy;)Z=Empr7jO#q?+oSFSCTcotZMHN7?F`QWPMRyP4h0vyf6qqGYFz*3^ylhuX}2F{ zu=q}Iz-2r?Ju~yi&Yg00Yy~+v_V@2P|5GQQVzIl<@8@T_3h?u@FRf41L^qBRg-PCH zHTL}*d~b4Uik~7XB0|B*$w^VM%fPY0eX%R0H56~q;J%1&a*7s@-Dpg5@^fozUZ+3J zAOx~qH87sBV>tV-SF{&lAVAUq?G9?JSjzeyi~@ey+CC9R`CpvsnD`C1w6u(kdB4Cw zs!2%&E8p+RqlxdJY*B;e{BbS?_b@g#wl-3Zhlf{FSqYSkiGyQQZKvn&e+kTpU5vr; zpXOp}c$^bEEvCnjoLw!<~7nmsu2K6flc>X8Ev<&PV-& z7c;V7nn3cYAw# zF|n4Dy(M0F9x*Z5d)bDi!#!!dj^kfm++#6}kMw_WH?KH9 zKbL$+5Z$i9m}p*6`{Bcf8sq9}#S6Ayz9OO^Ejc&_#>Vs*6TwY^A!N7{ZajAK0a~*s zJtZb4@Kn4vLoXyWv~+lNI{c&5&fy^?uS0rG4R45<{>R@??js|f+kaYIP=jywuL2n?;fh40q@meKOd61P$B2RNKnL-1ah-!q*e}He-(hBEmM0U1O0nT+wC*b(?f8`7gtxq=e(du#l*xs?`sRw z5J%i4An5Pw+ujPeO5+ds(;QS*R)&Lv16=7=ejDHNiEyW{NC03VD9(M`(FC4C8I+V780?L%vg5%*Z5qNB4!11@JS4{M5wilAkpMKAeS zSsm9$D~gMYr>CdEMw0V7d?+t3+>Q96M6~(x8>1_ zXD7b?W@dk(W858L*syQo;Wa_eqg|{RYT5hSjtz=i<6}+umH)gj4*@0LC>mR zj6{QE+gt1g|5{$ZD{o`N7~MVr`~$}=v2Sd4JF{&Vc$B^;3rX-*D6`fOI7>O1?Jv+& zx}axSTH3@~d6ROvkj2lKUePPV998ISIy$<`CDE&s?N&T@;pj7{IL5?P zARD+=23pJ94oK$j=IjS^u+#FM8{BrIY$=K!dy7i>>SN>M$cs~ViuO?4`!+wT;Y=6Q z`G`qC{&a7>``SCnhbxIal7ayK;OD!NUaw60z;pcZ;|KJc)lm|@rL9nBY-}tTuKh$# z&Lhw*pWvvkPhYiA$X7Q4JHf&dwK<)*Dwet`{fL%!;qu}P)Ly(SO=4oA?MN9k8R%NJ z2eQy7u#3!$j5ZDq)1Y>qQiF)+6@h{~Jv{|=;%HJDZgBJRPE1T__)UDRt%Y9e zi_m0Dlziyy?#^vtprbRAno72~wgtUlB zl!f* zcXmkG&2L1_flTxE_C{V8iRTEBfO+T!Bnt!e*yu-WKPLeI;R)S2Efx06h7a8(UCoWL`!P!^1-lXXl(I z5vxsiXJ=UiHL|q;n8=*(|0cH zf&eHfWqwKRh92SO;j#HSx~#gI$I1xdS6Q%>wz63Vfm**!74(jf)1SahzG@Tk_V3@Z z$+%>vA22X1uavp*V&f5s;lr{({p?fVhCaH3g|qVXZAsVu{{DKK8ty*BlPAD0Q2RzK zIJa-ZM~HWXH!*VUI;{DDo){Ag3JN?>T-@ARjEO)qeXbmwoDjUxDJE}&1BWUPvpLIF zFAk5001c>6zBoV6<$LUuF>WgF?p{?_Czui1(9n>Ah9H&7R|hw&x{O zmq{`z=&hy}8y~OX<;54>URhH^C5+^fixZgRbEg*-HKE3bENlSuZJYV68!sV(`P&-$ zh_lb83JL=Ne6Bs(6E=9MuD13qmFP4iFRZM+%|GPuEhB_Gv4UqSdqpC`!^gk6+dhBJ zAS}woyp{7mQ-nAkVJ{5FEEgAbZ0qWnLnOGLqjN{k&^sxee*P6 z$0r`-P%Y?1DEn0|^%?;PQa*j!uG?y?bNVZ85_&UG#vJJ21r&CF|L;s60&znYUI76C zW@dZ%v)_GUL>DUnCNpz4`ezoRq)sIT1qjF1)&dcxRsb)NQBl%7^a27%Sf}58EJPO* zC+Faga1*Sg&hD%wqRK0Yrfa_FK>qzcBgSYH3~7@TDd3gCq}03>YVL;EAHJUT#u#eM6h&$L8zE-MuXG=Z zi}}5_O{JyL;l^g#?;$S28-?U7QI9nWnDoCa@3d2mtT^=IYx0i4)d5P!YaFS>8Q9pi zf!f`yeQl})Ku|%H9g0U)fIG6_U!a(hyLSzd&5EWmwnBAvjKPhbpj~QjYddJ7>>*b= z3<(1-FE3JJk@CiMF}qN?csgd?Pft}S(GhMrotb4tnM_24gtBp(40iyUp~MLwenkfw z?CI$N_7#$g8|qWneDQXUuL0axL}8(|i_2$SCOSD6=#Y&j1|;iHp<;@;7wtPGkJ+1n zIi$yvC|4Re5ycwh&dv_xlcyZ?E2%cCxG9X{ z#dwT~SU@k9!ctGf@W~=W1k63x5D0Ahp8)ZSbe)}@Fp+?D)N7a0UcE5}k4$u9XEuVh z5mYOUocdUze$c53{te+V+K<|hZqKc)`M};njwZvojE>;R3?dK*Q_#$|o);~WD&qGw zBg1IvgA{jjGMm{q$k>an$-9-kJIE`mn46mel^Zd43}y{T>&`cX&^j%byO_`X&sd1R z0qP423w5-#s=jgp-)XP!`8R+11VMy$HB$3=E8q+G`sdk&2Mbx`5tQ zO5;&e3GC+5WJ2)Vs`J{(&CHQlA#?`|u%Abb-TI$kk$>3O1xy;fa=LDn&G5g)@P|+& zH%hRuvKFp(>?cfv1qP|_zH|a40qD88iGP}h9*DD?$Fg z@6O4|$szZ&H>V!8E6MEo(tT~Y$*vX^ha-c7utidlAo^%0wSCGy4QEOtOuyZ}eOo4) z@*>om$83MOAKbxz`WO3a;>~7c1!&Esxj9adhakWpRJC=KX<2Z|@|q3voc5-NLs+<> z{1sZDcS|4Rf6Y|$tQajZLL2u7270r;m;^531Ib%F?7+LPc7m>bGG-HDLuhwlO=G}h z1PBH_V3?xiShunLOzFf%Ynp!nROpcD|DsZi97GyyH{h;LHHU!v~|VdK1KTh zUj1kDws01~1QEC8n9DqgXo}`Vy_Vx<(2;=rD=RBtCqN$pRow>XTVtQ|1M_RGvA`9ry#zmX(+1sMa`! zNHmTHWk10)n2R20-|Ry{bqv_)vx!=IEuM2pp#D|zz%a}#x#zff-`d9ak3=Z0-%i^- z%@aG*+RDnx;+g=TIR!+sjo!#{^A{D-iv*eu@C#(*+z7jH_eea&TR1q0oeQ2@(+fLZ zNbOhH*mha)=ePJSo}NuUM`hwUB%D@nU#OuqTj`D+H-i8aJ>Py69j#(+J`3}l$hS~k zg`MW%jGUF9KV!a>RaX8Te^F%n%*NOBnQh$46g)hes&R1P&L~6S6G(J~f#pAH)6>zB zeRgFC3ffrXNYohHP1G@dAg9hmtdb=%H9g(fxdTl{M5oyIoD24ms~#)-r5QtC;^= z*8hKfTKlmI_)7kLD{!t>I#28Z(X=%iAv`;GjXcCG8Rn_7G=SVg6p_-8h$Bi%N@U|C zc`zyikI9mP*s(eJEdb4-KS1fUI;T@(x>Dqh9Ml|(V2fl?;o~&z8$QOTiqxDl@Ac8pJUjq*ei1#|$+1-g~gIkBuHUtILI~u`Ej~kQK!Z^6N z_QU8iw+{{$ocN@vZnciu@r0N`s@~+WK_(XmD+Nw{zjsRvA50)Dy3Tn{+H-+0Y7X+T z0{XPn)ZZTstnFk!Ff)RCd(T`KfC$*3MKA>bgUThA3EiWx_#sNKccTS-0Z`J8igC#a=5Chssd}q z>o~0lnDW3xPsew`#Tx`Gw6l>>hI~F2Hg-CXeJV_pq$Z$Vy5oR4>L>>DC;;#>7m9(g(5k`Sa%@ ze)9@1m^Sa*P~g&ol3E=qe)i+-V^-ECd2ivyEvNRqz5{H@w>Wu zoGgtv@vGjY$}zhX#RBX5DT;K2VaL9WIFq;bm>)NWOrxnJvjxekfW+q6kcAv^eXLSX zPtT2mR!*HIY4FLKi_$ieOCv{kBgpfq-**o9n&Y>moKTp!6nxoNOIHLM z40p&Pe?S-X3P6>@NA$9+*xI!$?&#DSsBcn!O6L$TG7qU90kwO$iY-rh=U`J zMy}+!Ii16zg|(&?CN}o_U%3FpEE@GFJu6>+ik@_S*O#we4Gs*zggquWXVngLVUhx5 z{#t9V?1D$lXKhu{r7zi+MyWDoqa+WM$Dw!Y*h)d+;CrCAX8%S7sXY_ z9qyucZ@-EN`Ja11$_PPPLw$X+g0~RmLCf@XX8Rn$4R&}1W>8$i1YVUnJ%FKaar>mL z_B$dIDypo6gge3F6@F(P#hT$vuq`4Z)~)8fq=l&x zHwz>oHdU!eT|!Dq5$hg`)v@ABp5}~Lnz7MQS^L~VE}xC6k@JfScPnj8O}nYDcMlj9 z-Z;9r>~8;U73}Tp=-~G~X8XKOpUE%#962S@RSAf7ELCn{$e}wQ;uCtq;E-*-=r82PQSXf) z3wcdui6-sreM7ebOadNvZ_L1(D;L$Nd{OBe<5wZ5y!WvTrFWDB->la8+oK^&*-4M{ z6qd_pssfz;JPddcjwkLY97cB|+tDg7Oc1%lhX|ZW$!~Bj#abB2R$<8_$jZig;X|p6 zdE(bUQL#}|D`9x`$$uwpImz;?pq>!%lsEZfk*B;@FRdg~9wj4mh4&iSRGB5N&MPij zE3dkn(e?R=H(4T6e?>KKHK`py7BwvtDRdcx z-WaW&DNe?GdhGqZCOAp%+NToi$)a+e-Hb#CkeObdI9Ir76 zH+Y1B5sD{z?lksUnfDe#*B_mDyyhy2bWD9Abizmx9n*)?6fugZG)WX89lgBLzSxQ% z;xx-39`&H0xiHb;tS+dkIw%jgur&$2K1#Rut~pbAW4H%1XE3;_H-{1v&nhARRq)St zJx_^@ce+CsFR0u;p*_8N60{+hiKc=p?)ZZmkKS#Cet zzNG;1jG1RyM|@wW0&xvxXvE8-o<{t5C}-f4hK~qDacrB=Rb8#r8ip6#Kc1OSU&WL}Xkj|04SEM~$r6ZQPOH)uEhn)L6Tsdg#T zIGnX&g<d&2eqUG}f`+o_KRi7(7 XYh^#L$t-=xe}9paQkE`j9PVgqcxlmCAgaEu4)3*;j*bpnV#o9|4uXEW zO1sLs+H|S+Su}HD6$_h(Cr*DyzxZq$2cCKmv^1XXjlIMnP9Y+=irk^3Y8Mq1Jv}{5 zPfH^pFVkfe%UqwI4>n6uKz}SKh>>Gpkk_xy!K`YnRogZub=~H+B6!e%3|^gRsPEWN z4U$|ZxrQ}8J?%a&t){7{7_NZsv#Si8SL7QT8PU5#G9~bPVf*FFm;Qc!PUCe0qoa28 zOr8Dx+#DPn;^MT@$Z6Nw-rnAuH*XRW5)L|gF8wJWe;sm}0QEdNT18crMDk=L_@m~7 z!otGaw{Lq0rJJo;R)~s-U=tvl7q;PsvvYDfIy&y%kT1#PR#EBh>FKGf6SBgzawz5W z{+Qe9=}{$UDPn1{8CO2tEeUKb&B5k=k9cW3H8&?sf+Zijzdl(~T+HhkUf7RexFK(3 zlwMNeY-)N(l4N3HqQN+**3d~#PVUj8mULk+A+`p$1tLk3yLaz)b#+ljw)I8cu78rm zY0+k1(tv#xIs7U+J3A{YYjkupA%UDnmX$pGF&#fYf8BM(_o{MZYGhhQM#ZYketv#y zdV7LSc1YS2`q5D={}`@rORnzWXS6YIU6)<6bvEZ-rJ5HA{+% zi%U(tZTBlIEbPJk`!PQ)#~b%C+Lg-NhbKozsQI+8Fua!>#I#~$;jQG3d}3k&10S_m z$qW0@lYZM>@hmz*zc2EqVQ*ikfSz%!jfc;xtn5S)jBjFNW83jij@kOH-NnVlWzqSR zt5{-O(&V#kWn;rENmA`J{_EGTqun7BVWaDK9E(pVBR^zi1+|(NXg+u;%^*Mc=g$vI zt_EZ0p5d|j?tplOr%eH;N+H*;VNq*XcJ`}?JF23@&kknY7JesJuzkTNL?vq%qPOeh z7$`@SnaQ#RcjC;(?#BK6ZqAwN_<75sVuUW{N2mSSt5>h~Cj&Ft8W`lAhD*P8_TfV^ z6W+chF|L<@97f6`A_z25{EnmU^PRWC>8|WI zHwXW%b;HpzNpGY#6V6q)wY9}eeSIk#frL9%y=?%GsM_IXYYQ>pb<+@`A#RXA!sKOI zSz8-Wk}|{DJiNR{^`3+kIO+wO?^O?ujsOTWoqC1e-w6GFlDgL|cDyp~HQDOocY3rt zKe^Av*|>^>5Mk?~$GHHR|MxHz-rCyAKfm|@l(0|~V@WsF#Q9;8+0Z8=_wxH;sI}S! z6uWuW{;hvFDS!@zg}$9J5Pl2>6GB7{#bWE=@W-yQxVU(3ZZ1zHGhn@8KbYXwR?@6R zI4U2}oi3bMt7mYgzEXxBV*7Z_;wsX|q1UubmqokC#?FrG`mM0+zP`SdrLkY-(KMw1(RLF(w)3Ad1XIpcv$zODZ^*}RwoSu6H|`M4RdpI0x_Zey*(@K z;EVTHuf()W`|Xc=b@q)74ps%69wj9u!S~vln#VFSY({mTb91ql3$w7X6VwU^u3o*m zb08{ew>i_=X~JKinF4Rl%v9;V?^NxvJh-#tUa23s>+)g!x$uOJ$ni=AlXBWysHjL8 zx){r+Pg~pCxO4 zp*H7p?16$_YiUZ18V};V)+fdr{e7Q3+sKl9nIx?L#k&mi84CwzeYCPLJ6kHRzt_a7 zuBHZ_(s{UadZsm8OG`@utzJ05$IE+@jBI7Bdi`)AlmAWVA!c=pv1@$%w77ag$ZcN9 z!=qBYzyUh2sOaSEc%6b(x5qZxPCG5#$$=CTN}1F?%ge*V!7u#u7a8|Z*YeVaeo8-U z7p|z6dR-pEq|xsXDv+8xU%fy{N$K+^MAOzUmi*gi%23|KyGG5ANxlPu31e#)XhMQB z4zGHDe{(%DGLl;5@cp9@rFL;9rVyXy0i&zlqn~YEYyS?**xB1d*lbF}Zdxx~-+rV* zF%?S6#8qwE>*1R5v&CLoxq^pj=dU~_!*{C8qW!~%51wh=3)>JL$09d7(hpBkJx>l? zV&p7Yn~U@+h-k$kP>4MLgXs{r`A!THo~GEOX`<7pzNQ8;!dXAfHyn;DFE2kR>J5N}$QHlqAk&f095f$yd^IJ|sLqlIbMVEDNcNbdkq1*UKg>}FE z;o+ez4<$-XQL$qO)1RK6-tgrMpTn@}hOl)uTr`Z~C$IhB!SOLZ)(h(lAFGyNLgwLw zhXzDc0vo?rN*+r~@2*c~woJciodG~nOl0d_UfMmnbOl@YoiVA_tHeYdMMZpWwSt0z z%g-g4$=LMD9~Tbvh0}<5H&f}1E>&3A225>w!z>-#JvWbO@ZIOyZ#4aSd3&gf+ts_c z!&ZCYRffR*aqXbwE@ZdpK9yTWIH1qD1e48(p>8jVJ`4eB@g3Az_zQbA6hZl-v8`yp>5B8ccg4&p&7Dl zy7sAmsRL;dGE|pl<)OS78rLV9*yCDz@*{?RCs&g8#oF2$x#PpTcb}h`Fvgv3XP#TK ziTWS$oCfOZ4mUr)(tB>v7GZ`xR`12`<_f@LwPsW}5U@95-IgGB_%AM%-?8i_bqPeURs&sJ!?-&V0@y zcWcYF!B|Bl`%;08&6L$P+-;uon(ZF7& z6NqvitLjQ)sb<3&<)G!p;o;#yMzmXV)G%(>?EW3WJjABX33>fm^oZ}Y=$GwyEGcR+ zSGQJQLxa?VKX4vl&N6O`IUVAGeRuf)&qf@;`sa&4aV-40J32A9c zB#$K|nxWch9{+Qh&>v70RPF&i6O zr>Uf*q?J?8@O#S%cUztm{t54v_V$o2t62dW5|W_ZllL1tJ3Q&kZrJOK#`&8otJRYe zbsqLA>&uWn=-M4iZ||DJ?&tOVdAkbArSC60^mKc)hd79zpKzZx0eiTHRpWBJxBBQV zA+})}rv-e}8{+90i+U zGwSEBcott2#e?`I+VcY(H)Sg;tGrp0S%Hj<3@2rlL=VBPrL?I#SCbBf!!q3J>gu=_ zRz@m98b?UOTj6|f3_9`4moEZTQL4-Y`1s+`(SZx;Udowb{);_#QN@o!h&aQ4)p;zd ztE;D_r5)|BpKrF)dif1}RqvEc%k-YX-9a>ujrsnJy~jw|&?#SnTko|VaIkS#wAOQV zbjT#I!K5hwDl&YmHyi+)VAPa`>9y1*aFDSZ zSoPF(b!+U0*xW{+7hSX#mO)^AU#CIV+37Jittr<-gDQg8KBv1w^0B#fu1kOJ&nJi< zEz4i&o{ymVT^|!P|J$Y<8SfppPrk2x-Jdx7#_jK5M!ulPMd~2>FA!9~l zR<TeSDdccI)%k;Rx}B<*hiz91FE+70Nu|Eyvq5pag0yOZAgA)K-!WhGVO;xt$upgF}HPaa6l}6 zQd(O(;{IcB?|NIECBU)}lL&Tn!S&@X{z=2nHod^^Qc_bReavkQ{JvX=&+&-{}U`PDZY-tCFWOCRHVch2fwVU zs`A}mdunZ6Vpz*XNB5%f>1r%ma8=Fqa%mqxa8e@k_+b|gBFn(r$7j68W%hJ`l2#P8 z6Ljec)LnRZN4{DP7dLk@dVjtv^;z?!QbFv8m(o!O8`JOyl<)@xq67b#np&@his)5Y zB7q0>v{S-DLa42OKSM)9&(6-o#KfqG@&HP*vuC2k&jft7EImD|l=GHX1unGbe-4Sv zr~Ab{(QjJKuVQ0|wVKmJ=K+6Jw}qH``8znK9+}1-`USYUx+*C})YsP=vUS7p`!|f? z@$ya3gG!lVqJo0AmI;l#tZv=Bi5{@4XQ||C?e2E&OyUd(2oQ*ZiU1yT6$^`iNIFNyyZ!*TOixlYO-!77q+>jQV1cx0Je#GkZ8 z+A%WXGhAv0{RoZnfELLq`Z|~5`*ZvRzEA77 zA!%$p@jG2OJUqM?D&S2-Lg$E&V*$P(I6Gd(MxU0KmV_*SkPs6SzqkW68XguF z;i0?xMweCF-QC^7Vz%jQmwo;O`qS6jn}?52Mpm}JflVTIb%zyn4O%gAer~Qe(~O>8 zCgM$GWLHOrL7ls;lhY>!Gz!?T_{m1g&dv@%N8eXIr@zcMLRE*@T0)3bU?dX~4q)72 zjtG0Mtb&XHy78*$q5Lq!4i|no*s!38Y-%^j0_r5(v~?_5D*|dmFIg@WyG1NM7%fP z1gHZSm(AVfAws9O�oq!sj{->t1BYS&$7PnIT7&#ul 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 literal 9537 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 TvdW7eOCc0x)nrN@nuh)tzIUuE diff --git a/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png b/tests/snapshots/v3/test_footswitch_state_view/test_two_track_looper_footswitch_bar/recording-and-playback.png index 8f12ea99b23c0929f19e019253928eaffc9fc56c..6e53604b8f28da994120c4f017b3bc5a8ab2fc04 100644 GIT binary patch literal 10103 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 L$O;z=>AwG832Wl0 literal 10317 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 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 - ]) + @pytest.mark.parametrize( + "state,expected_color", + [ + (0, None), # Empty -> off + (1, (0, 80, 255)), # Record Arm -> blue + (2, (255, 0, 0)), # Recording -> red + (3, (0, 80, 255)), # Record Close -> blue + (4, (0, 255, 0)), # Playback -> green + (5, (80, 80, 80)), # Stopped -> steady grey + (6, (0, 80, 255)), # Overdub Arm -> blue + (7, (255, 140, 0)), # Overdub -> orange + (8, (0, 80, 255)), # Overdub Close -> blue + ], + ) def test_state_color_with_nonzero_measure(self, state, expected_color): color, _style = render_led_spec(_spec(), {"state": float(state), "measure_number": 1.0}) assert color == expected_color - @pytest.mark.parametrize("state,expected_style", [ - (0, LedDisplayStyle.SOLID), # Empty -> off, solid - (5, LedDisplayStyle.SOLID), # Stopped -> steady grey - (1, LedDisplayStyle.METRONOME), # active -> pulse - (2, LedDisplayStyle.METRONOME), - (3, LedDisplayStyle.METRONOME), - (4, LedDisplayStyle.METRONOME), - (6, LedDisplayStyle.METRONOME), - (7, LedDisplayStyle.METRONOME), - (8, LedDisplayStyle.METRONOME), - ]) + @pytest.mark.parametrize( + "state,expected_style", + [ + (0, LedDisplayStyle.SOLID), # Empty -> off, solid + (5, LedDisplayStyle.SOLID), # Stopped -> steady grey + (1, LedDisplayStyle.METRONOME), # active -> pulse + (2, LedDisplayStyle.METRONOME), + (3, LedDisplayStyle.METRONOME), + (4, LedDisplayStyle.METRONOME), + (6, LedDisplayStyle.METRONOME), + (7, LedDisplayStyle.METRONOME), + (8, LedDisplayStyle.METRONOME), + ], + ) def test_state_style(self, state, expected_style): _color, style = render_led_spec(_spec(), {"state": float(state), "measure_number": 1.0}) assert style == expected_style diff --git a/tests/v3/test_footswitch_led_driver.py b/tests/v3/test_footswitch_led_driver.py index 2d43cf59f..ed048bdbb 100644 --- a/tests/v3/test_footswitch_led_driver.py +++ b/tests/v3/test_footswitch_led_driver.py @@ -5,10 +5,9 @@ fs.led are written from the same (color, style) frame in the same driver call — no separate set_led path. -Covers: - 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). + - METRONOME: is fully on during the transport flash window and off otherwise. + - Unanchored METRONOME: steady color (no transport pulse). - Off: color is None -> pixel disabled, GPIO LED off. - Press renders in the same tick (poll_controls, not poll_indicators). - set_led is a pure state update -- no hardware writes. @@ -27,8 +26,9 @@ 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: +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( @@ -82,30 +82,27 @@ 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): + def test_metronome_on_during_flash_window(self, v3_system: SystemFixture): fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) - _drive(v3_system.handler, _beat(beat_phase=0.0)) - # phase 0 → brightness 1.0 → unscaled color + _drive(v3_system.handler, _beat(beat_phase=0.9, is_flashing=True)) fs.pixel.set_color.assert_called_once_with((100, 100, 100)) + fs.pixel.set_enable.assert_called_once_with(True) - def test_metronome_dim_near_phase_one(self, v3_system: SystemFixture): + def test_metronome_off_after_flash_window(self, v3_system: SystemFixture): fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) - _drive(v3_system.handler, _beat(beat_phase=0.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) + _drive(v3_system.handler, _beat(beat_phase=0.0, is_flashing=False)) + fs.pixel.set_enable.assert_called_once_with(False) + assert fs.led is not None + fs.led.off.assert_called_once() # type: ignore[unionAttr] - def test_metronome_brightest_on_bar_start(self, v3_system: SystemFixture): + def test_metronome_bar_start_uses_downbeat_color(self, v3_system: SystemFixture): fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) - _drive(v3_system.handler, _beat(beat_phase=0.5, is_bar_start=True)) - # bar start forces brightness 1.0 even though phase is mid-beat + _drive(v3_system.handler, _beat(beat_phase=0.5, is_bar_start=True, is_flashing=True)) fs.pixel.set_color.assert_called_once_with((100, 100, 100)) def test_metronome_unanchored_shows_steady_color(self, v3_system: SystemFixture): fs = _fs_with_frame(v3_system, (100, 100, 100), LedDisplayStyle.METRONOME) _drive(v3_system.handler, _beat(beat_phase=0.9, is_anchored=False)) - # No pulse when unanchored — full color fs.pixel.set_color.assert_called_once_with((100, 100, 100)) diff --git a/tests/v3/test_footswitch_state_view.py b/tests/v3/test_footswitch_state_view.py index 717f1e0c4..213556f06 100644 --- a/tests/v3/test_footswitch_state_view.py +++ b/tests/v3/test_footswitch_state_view.py @@ -10,8 +10,9 @@ from common.loop_progress import LoopFill, LoopProgress from common.parameter import Symbol, Type from modalapi.plugin import Plugin +from modalapi.ws_protocol import BeatSyncMessage from plugins import lookup -from pistomp.beatsync import TickState +from pistomp.beatsync import FLASH_US, TickState from plugins.loopjefe import LOOPJEFE_URIS from tests.types import SystemFixture @@ -93,14 +94,18 @@ def test_state_view_repaints_on_output_set(v3_system: SystemFixture, make_parame snapshot("overdub-from-output-set") -def _beat(handler, bar_phase: float, beat_phase: float = 0.0, is_bar_start: bool = False) -> None: - """Drive one LED tick with a synthetic transport phase. The LCD reads the - phase from the handler's last tick rather than the grid, so this is the - only way in.""" +def _beat( + handler, + bar_phase: float, + beat_phase: float = 0.0, + is_bar_start: bool = False, + is_flashing: bool = True, +) -> None: + """Drive one LED tick with a synthetic transport state.""" handler._drive_footswitch_leds( TickState( is_anchored=True, - is_flashing=False, + is_flashing=is_flashing, is_bar_start=is_bar_start, bpm=120.0, bpb=4.0, @@ -127,7 +132,7 @@ def test_progress_border_fills_by_bar_and_beat(v3_system: SystemFixture, make_pa v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0") handler.poll_ws_messages() - _beat(handler, 0.5) + _beat(handler, 0.5, is_flashing=True) lcd.update_footswitches() snapshot("play-bar3-beat3") @@ -146,11 +151,46 @@ def test_progress_border_chases_while_recording(v3_system: SystemFixture, make_p v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 2.0") # Recording handler.poll_ws_messages() - _beat(handler, 0.25) + _beat(handler, 0.25, is_flashing=True) lcd.update_footswitches() snapshot("recording-chase") +def test_progress_border_snapshots_at_integer_beat_syncs(v3_system: SystemFixture, make_parameter, snapshot): + """Capture each integer beat from the transport's synchronized clock.""" + handler = v3_system.handler + lcd = handler.lcd + _bind(v3_system, [_loopjefe(make_parameter, "loopjefe_1")]) + + lcd.link_data(handler.pedalboard_list, handler.current, v3_system.hw.footswitches) + lcd.draw_main_panel() + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 4.0") # Playback + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0") + v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 measure_number 2.0") + handler.poll_ws_messages() + + anchor_us = 1_000_000 + beat_period_us = 500_000 + for beat_in_bar in range(4): + source_us = anchor_us + beat_in_bar * beat_period_us + v3_system.ws_bridge.inject(f"beat_sync {source_us} 120.0 4 {beat_in_bar}.0") + handler.poll_ws_messages() + state = handler.beat_grid.tick(source_us) + assert state.beat_phase == 0.0 + assert state.bar_phase == beat_in_bar / 4.0 + assert state.is_flashing is True + handler._drive_footswitch_leds(state) + lcd.update_footswitches() + snapshot(f"transport-beat-{beat_in_bar}-on") + + cutoff = anchor_us + 3 * beat_period_us + FLASH_US + state = handler.beat_grid.tick(cutoff) + assert state.is_flashing is False + handler._drive_footswitch_leds(state) + lcd.update_footswitches() + snapshot("transport-beat-3-off-50000us") + + def test_overdub_past_declared_length_chases(v3_system: SystemFixture, make_parameter): """An overdub that outruns the head loop's bar count has no denominator left, so it degrades to the chaser rather than filling past 100%.""" @@ -174,9 +214,7 @@ def test_overdub_past_declared_length_chases(v3_system: SystemFixture, make_para def test_progress_border_pulses_with_the_beat(v3_system: SystemFixture, make_parameter): - """The border carries the same envelope as the physical LED -- full on the - downbeat, decaying across the beat -- so the slot and its switch never - pulse out of step. Only the states the spec says pulse are affected.""" + """The border and physical LED share the binary transport flash state.""" handler = v3_system.handler plugins = [_loopjefe(make_parameter, "loopjefe_1")] _bind(v3_system, plugins) @@ -186,13 +224,13 @@ def test_progress_border_pulses_with_the_beat(v3_system: SystemFixture, make_par v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 loop_bars 4.0") handler.poll_ws_messages() - _beat(handler, 0.0, beat_phase=0.0, is_bar_start=True) - downbeat = handler.footswitch_loop_progress(fs) - _beat(handler, 0.2, beat_phase=0.8) - late = handler.footswitch_loop_progress(fs) - assert downbeat is not None and late is not None - assert downbeat.pulse == 1.0 - assert late.pulse < downbeat.pulse + _beat(handler, 0.0, beat_phase=0.0, is_bar_start=True, is_flashing=True) + during = handler.footswitch_loop_progress(fs) + _beat(handler, 0.2, beat_phase=0.8, is_flashing=False) + outside = handler.footswitch_loop_progress(fs) + assert during is not None and outside is not None + assert during.pulse == 1.0 + assert outside.pulse == 0.0 v3_system.ws_bridge.inject("output_set /graph/loopjefe_1 state 5.0") # Stopped: steady handler.poll_ws_messages() @@ -202,8 +240,7 @@ def test_progress_border_pulses_with_the_beat(v3_system: SystemFixture, make_par def test_progress_border_is_steady_without_transport(v3_system: SystemFixture, make_parameter): - """No beat_sync, no envelope -- an unanchored grid must not leave the - border stuck at whatever brightness the last phase happened to be.""" + """Without transport, the border remains at its steady brightness.""" handler = v3_system.handler plugins = [_loopjefe(make_parameter, "loopjefe_1")] _bind(v3_system, plugins) diff --git a/tests/v3/test_taptempo_led.py b/tests/v3/test_taptempo_led.py index 302e6b0b9..d7e779226 100644 --- a/tests/v3/test_taptempo_led.py +++ b/tests/v3/test_taptempo_led.py @@ -4,8 +4,8 @@ - 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. + blinks from taptempo.anchor + bpm — on for the first 50ms of each beat + period, off otherwise. - Taptempo disabled: the footswitch behaves as a default toggle — no metronome flash, whether or not the transport is anchored. @@ -48,8 +48,7 @@ def test_flashing_beat_shows_beat_color(self, v3_system: SystemFixture): fs = _find_taptempo_fs(v3_system) _mock_fs(fs) _enable_tap(fs) - beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=False, - bpm=120.0, bpb=4.0, beat_phase=0.0) + 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) @@ -60,8 +59,7 @@ def test_flashing_bar_start_shows_downbeat_color(self, v3_system: SystemFixture) fs = _find_taptempo_fs(v3_system) _mock_fs(fs) _enable_tap(fs) - beat = TickState(is_anchored=True, is_flashing=True, is_bar_start=True, - bpm=120.0, bpb=4.0, beat_phase=0.0) + 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) @@ -69,8 +67,7 @@ def test_not_flashing_turns_off(self, v3_system: SystemFixture): fs = _find_taptempo_fs(v3_system) _mock_fs(fs) _enable_tap(fs) - beat = TickState(is_anchored=True, is_flashing=False, is_bar_start=False, - bpm=120.0, bpb=4.0, beat_phase=0.5) + 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 @@ -87,14 +84,13 @@ def test_taptempo_blink_on_within_flash_window(self, v3_system: SystemFixture): _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.set_bpm(120.0) # 120bpm → 500ms period, 50ms 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)): + # Now=1000.025 → 25ms into the beat → within the 50ms on-window → ON + with patch("modalapi.modhandler._now_us", return_value=int(1000.025 * 1_000_000)): v3_system.handler._drive_footswitch_leds( - TickState(is_anchored=False, is_flashing=False, is_bar_start=False, - bpm=120.0, bpb=4.0, beat_phase=0.0) + 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 @@ -104,15 +100,13 @@ 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.set_bpm(120.0) # 500ms period, 50ms on-window fs.taptempo.anchor = 1000.0 - # Now=1000.3 → 300ms into the beat → past the 100ms on-window → OFF + # Now=1000.3 → 300ms into the beat → past the 50ms on-window → OFF with patch("modalapi.modhandler._now_us", return_value=int(1000.3 * 1_000_000)): v3_system.handler._drive_footswitch_leds( - TickState(is_anchored=False, is_flashing=False, is_bar_start=False, - bpm=120.0, bpb=4.0, beat_phase=0.0) + 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 @@ -126,8 +120,7 @@ def test_taptempo_zero_bpm_does_not_blink(self, v3_system: SystemFixture): 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) + 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) @@ -141,8 +134,7 @@ def test_taptempo_disabled_falls_through_to_default(self, v3_system: SystemFixtu 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) + 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) @@ -158,12 +150,12 @@ def test_no_gpiozero_blink_called(self, v3_system: SystemFixture): 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) + TickState(is_anchored=False, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.0) ) assert fs.led is not None fs.led.blink.assert_not_called() # type: ignore[unionAttr] + def test_anchored_transport_does_not_flash_when_disabled(v3_system: SystemFixture): """Transport anchored but tap tempo mode off → no metronome flash; the switch renders from its own binding like any other.""" @@ -173,7 +165,6 @@ def test_anchored_transport_does_not_flash_when_disabled(v3_system: SystemFixtur fs.taptempo.enable(False) fs.toggled = True v3_system.handler._drive_footswitch_leds( - TickState(is_anchored=True, is_flashing=False, is_bar_start=False, - bpm=120.0, bpb=4.0, beat_phase=0.5) + TickState(is_anchored=True, is_flashing=False, is_bar_start=False, bpm=120.0, bpb=4.0, beat_phase=0.5) ) fs.pixel.set_enable.assert_called_once_with(True) # the flash would have blanked it diff --git a/uilib/footswitch.py b/uilib/footswitch.py index bcb0adf1e..df498afdd 100644 --- a/uilib/footswitch.py +++ b/uilib/footswitch.py @@ -63,14 +63,14 @@ def get_bpm(self) -> float: ... # Title white — same (255,255,255) used for pedalboard/snapshot titles. TITLE_WHITE: Color = (255, 255, 255) -# Loop progress border: inset 1px all round, same radius/weight as the tap -# border so the two views read as the same frame. +# Loop progress border: inset 1px all round, same weight as the tap border. PROGRESS_INSET = 1 PROGRESS_RADIUS = 5 PROGRESS_THICKNESS = 2.0 PROGRESS_GAP = 3.0 # notch between bars, px of arclength CHASE_SPAN = 0.18 # turns of perimeter the indeterminate head covers PULSE_STEPS = 8 # brightness quantisation; each step past this is a slot repaint +TAP_FLASH_SECONDS = 0.05 # The unfilled track, as a fraction of the state colour. TRACK_DIM = 0.28 @@ -104,6 +104,7 @@ class FootswitchWidget(Widget): # Two-line state view, same rhythm as the tap view. _STATE_Y_NAME = 2 _STATE_Y_STATE = 17 + _STATE_FONT_SIZE = 13 font: pygame._freetype.Font small_font: pygame._freetype.Font | None @@ -212,31 +213,37 @@ def _draw_tap(self, ctx: PaintContext) -> None: ctx.draw_text(((w - dw) // 2, self._TAP_Y_BPM), digits, fill=self.TAP_BPM_COLOR, font=bpm_font) def _draw_state(self, ctx: PaintContext, w: int) -> None: - """Two-line view for a switch whose plugin publishes a state: name on - top, state below in the state's own color. No dot — the coloured state - word is the indicator, and 36px holds two lines or a dot, not both.""" + """Two-line view for a switch whose plugin publishes a state.""" self._draw_progress(ctx, w, ctx.height) - font = self._slot_font() + name_font = self._slot_font() + state_font = Config().get_font("footswitch_badge") if self.loop_icon: - self._draw_loop_name(ctx, w, font) + self._draw_loop_name(ctx, w, name_font) else: - name = self._fit(self.label or "", w - 2, font) - nw, _ = get_text_size(name, font) - ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=font) + name = self._fit(self.label or "", w - 2, name_font) + nw, _ = get_text_size(name, name_font) + ctx.draw_text(((w - nw) // 2, self._STATE_Y_NAME), name, fill=self.BOUND_OFF_LABEL, font=name_font) - state = self._fit(self.state_label or "", w - 2, font) - sw, _ = get_text_size(state, font) + state = self._fit((self.state_label or "").upper(), w - 2, state_font) + sw, _ = get_text_size(state, state_font, self._STATE_FONT_SIZE) fill = self.color if self.color is not None else self.BOUND_OFF_LABEL - ctx.draw_text(((w - sw) // 2, self._STATE_Y_STATE), state, fill=fill, font=font) + ctx.draw_text( + ((w - sw) // 2, self._STATE_Y_STATE + 1), + state, + fill=fill, + font=state_font, + size=self._STATE_FONT_SIZE, + ) def _draw_loop_name(self, ctx: PaintContext, w: int, font: "pygame._freetype.Font") -> None: """Render the racetrack glyph + track number instead of 'Loop N' text.""" import re + label = self.label or "" m = re.search(r"\d+$", label) num_str = m.group() if m else "" - glyph = LoopIconGlyph() # 48×14 default; module-level cache makes this free + glyph = LoopIconGlyph() # 48×14 default; module-level cache makes this free gap = 4 nw, _ = get_text_size(num_str, font) if num_str else (0, 0) total_w = glyph.width + (gap + nw if num_str else 0) @@ -246,8 +253,7 @@ def _draw_loop_name(self, ctx: PaintContext, w: int, font: "pygame._freetype.Fon ox, oy = ctx._f().topleft ctx.surface.blit(tint_mask(glyph.render(), self.BOUND_OFF_LABEL), (gx + ox, gy + oy)) if num_str: - ctx.draw_text((gx + glyph.width + gap, self._STATE_Y_NAME), num_str, - fill=self.BOUND_OFF_LABEL, font=font) + ctx.draw_text((gx + glyph.width + gap, self._STATE_Y_NAME), num_str, fill=self.BOUND_OFF_LABEL, font=font) def _draw_dot_and_label(self, ctx: PaintContext, w: int, is_on: bool) -> None: """Small dot on top, label centered below.""" @@ -302,7 +308,6 @@ def refresh(self, box=None): else: super().refresh(box) - def _progress_glyph(self, w: int, h: int) -> PerimeterProgressGlyph: return PerimeterProgressGlyph( w - 2 * PROGRESS_INSET, h - 2 * PROGRESS_INSET, PROGRESS_RADIUS, PROGRESS_THICKNESS @@ -386,7 +391,7 @@ def tick(self) -> None: return period = 60.0 / bpm phase = (time.monotonic() - taptempo.anchor) % period - on = phase < period / 4 + on = phase < TAP_FLASH_SECONDS if on != self._pulse_on: self._pulse_on = on self.refresh() diff --git a/uilib/glyphs/perimeter_progress.py b/uilib/glyphs/perimeter_progress.py index ffdd3571c..65a6f686e 100644 --- a/uilib/glyphs/perimeter_progress.py +++ b/uilib/glyphs/perimeter_progress.py @@ -15,16 +15,16 @@ # You should have received a copy of the GNU Affero General Public License # along with pi-stomp. If not, see . -"""Rounded-rectangle perimeter used as a progress track. +"""Chamfered perimeter used as a progress track. -Renders an **alpha mask** (white RGB, coverage in alpha) of the arc from -`start` to `end` measured as arclength around a rounded rect, clockwise from -top-centre. `segments` notches the track at each 1/n boundary, so a 4-bar loop -reads as four arcs rather than one continuous stroke. +Renders an **alpha mask** (white RGB, coverage in alpha) of the polygonal path +from `start` to `end` measured as arclength around a chamfered rectangle, +clockwise from top-centre. `segments` notches the track at each 1/n boundary, +so a 4-bar loop reads as four angular arcs rather than a rounded box. -Geometry is cached per (size, radius, thickness); `render` is not — a mask is -~2700 pixels of numpy and a progress sweep visits every pixel step, so caching -the masks would cost megabytes to save microseconds. +The path is an analytic distance field over straight vector edges. Geometry is +cached per (size, chamfer, thickness); `render` is not, because a progress +sweep visits every pixel step. Unlike the disc glyphs this is sized to a box, not a radius: blit at the box's top-left, no centring offset. @@ -38,73 +38,60 @@ import numpy as np import pygame -_TWO_PI = 2.0 * math.pi - @lru_cache(maxsize=16) def _geometry(width: int, height: int, radius: int, thickness: float) -> tuple[np.ndarray, np.ndarray, float]: - """(coverage, t, perimeter) for a rounded-rect outline. - - `coverage` is the anti-aliased stroke band; `t` is each pixel's position - in [0, 1) along the path, taken at the nearest point on it. Pixels well - inside the shape have no nearest point in any meaningful sense, but their - coverage is 0, so `t` there is arbitrary rather than wrong. - """ - a = width / 2.0 - b = height / 2.0 - r = min(float(radius), a, b) - ex = a - r # half-length of the horizontal straights - ey = b - r - - quarter = 0.5 * math.pi * r - perimeter = 4.0 * ex + 4.0 * ey + _TWO_PI * r + """Return coverage, normalized path position, and polygon perimeter.""" + w = float(width) + h = float(height) + chamfer = min(max(float(radius), 0.0), w / 2.0, h / 2.0) + points = ( + (w / 2.0, 0.0), + (w - chamfer, 0.0), + (w, chamfer), + (w, h - chamfer), + (w - chamfer, h), + (chamfer, h), + (0.0, h - chamfer), + (0.0, chamfer), + (chamfer, 0.0), + ) x = np.arange(width, dtype=float) + 0.5 y = np.arange(height, dtype=float) + 0.5 X, Y = np.meshgrid(x, y) - dx = X - a - dy = Y - b - - # Nearest point on the inner rect; the path is that point pushed out by r. - ix = np.clip(dx, -ex, ex) - iy = np.clip(dy, -ey, ey) - vx = dx - ix - vy = dy - iy - dist = np.hypot(vx, vy) - coverage = np.clip(thickness / 2.0 + 0.5 - np.abs(dist - r), 0.0, 1.0) - - # Arclength offsets, clockwise from top-centre: the top edge is split so - # t=0 lands mid-top rather than on a corner. - o_tr_arc = ex - o_right = o_tr_arc + quarter - o_br_arc = o_right + 2.0 * ey - o_bottom = o_br_arc + quarter - o_bl_arc = o_bottom + 2.0 * ex - o_left = o_bl_arc + quarter - o_tl_arc = o_left + 2.0 * ey - o_top_left = o_tl_arc + quarter - - theta = np.arctan2(vy, vx) - past_x = np.abs(dx) > ex - past_y = np.abs(dy) > ey - right = dx > 0 - below = dy > 0 - - s = np.where(right, dx, o_top_left + ex + dx) # top edge, split at centre - s = np.where(past_y & below, o_bottom + (ex - dx), s) - s = np.where(past_x & ~past_y & right, o_right + (dy + ey), s) - s = np.where(past_x & ~past_y & ~right, o_left + (ey - dy), s) - s = np.where(past_x & past_y & right & ~below, o_tr_arc + r * (theta + 0.5 * math.pi), s) - s = np.where(past_x & past_y & right & below, o_br_arc + r * theta, s) - s = np.where(past_x & past_y & ~right & below, o_bl_arc + r * (theta - 0.5 * math.pi), s) - s = np.where(past_x & past_y & ~right & ~below, o_tl_arc + r * (theta + math.pi), s) - - return coverage, np.mod(s / perimeter, 1.0), perimeter + best = np.full((height, width), np.inf) + position = np.zeros((height, width), dtype=float) + offset = 0.0 + + for start, end in zip(points, points[1:] + points[:1]): + x0, y0 = start + x1, y1 = end + dx = x1 - x0 + dy = y1 - y0 + length = math.hypot(dx, dy) + if length == 0.0: + continue + u = np.clip(((X - x0) * dx + (Y - y0) * dy) / (length * length), 0.0, 1.0) + nearest_x = x0 + u * dx + nearest_y = y0 + u * dy + distance = np.hypot(X - nearest_x, Y - nearest_y) + closer = distance < best + best = np.where(closer, distance, best) + position = np.where(closer, offset + u * length, position) + offset += length + + coverage = np.clip(thickness / 2.0 + 0.5 - best, 0.0, 1.0) + perimeter = offset + return coverage, np.mod(position / perimeter, 1.0), perimeter class PerimeterProgressGlyph: - """Progress track around a rounded rect. `render()` returns an alpha mask - the size of the box; blit it at the box's top-left.""" + """Progress track around a chamfered rectangle. + + `render()` returns an alpha mask the size of the box; blit it at the box's + top-left. + """ def __init__(self, width: int, height: int, radius: int, thickness: float = 2.0) -> None: self._width = int(width) diff --git a/uilib/paint.py b/uilib/paint.py index 92e5da75f..865b358a2 100644 --- a/uilib/paint.py +++ b/uilib/paint.py @@ -68,19 +68,23 @@ def _ipt(p: Sequence[int]) -> Point: @lru_cache(maxsize=512) -def _text_surface(text: str, font: "pygame._freetype.Font", color: Tuple[int, int, int, int]) -> pygame.Surface: - """Cached RGBA surface of `text`, pen origin at (_TEXT_PAD, _TEXT_PAD + ascender).""" +def _text_surface( + text: str, font: "pygame._freetype.Font", color: Tuple[int, int, int, int], size: int = 0 +) -> pygame.Surface: + """Cached RGBA surface of a text run, with optional font size override.""" from uilib.misc import get_text_size # local: uilib.misc imports uilib.paint - asc = int(font.get_sized_ascender()) - desc = abs(int(font.get_sized_descender())) - tw, _ = get_text_size(text, font) + asc = int(font.get_sized_ascender(size)) + desc = abs(int(font.get_sized_descender(size))) + tw, _ = get_text_size(text, font, size) surf = pygame.Surface((tw + 2 * _TEXT_PAD, asc + desc + 2 * _TEXT_PAD), pygame.SRCALPHA) surf.fill((0, 0, 0, 0)) prev_origin = font.origin font.origin = True try: - font.render_to(surf, (_TEXT_PAD, _TEXT_PAD + asc), text, fgcolor=pygame.Color(*color)) + font.render_to( + surf, (_TEXT_PAD, _TEXT_PAD + asc), text, fgcolor=pygame.Color(*color), size=size # pyright: ignore[reportCallIssue] + ) finally: font.origin = prev_origin return surf @@ -229,39 +233,28 @@ def draw_text( fill: Optional[ColorLike] = None, font: Optional["pygame._freetype.Font"] = None, # pyright: ignore[reportAttributeAccessIssue] anchor: Optional[str] = None, + size: int = 0, ) -> None: """Draw text using a pygame._freetype Font. Default anchor matches PIL's `la` (left, ascender): `pos` is the - top-left of the line box (ascender line), not of the visible glyph - bbox. This keeps text vertical alignment consistent regardless of - which characters appear (with/without ascenders or descenders). - Also supports anchor='mm' (middle/middle of the glyph bbox). + top-left of the line box, not of the visible glyph. `size` overrides + the font's registered size without mutating the shared font. """ if not text or font is None or fill is None: return color = _color(fill) x, y = self._abs_xy(pos) - asc = int(font.get_sized_ascender()) + asc = int(font.get_sized_ascender(size)) if anchor == "mm": - # PIL anchor='mm' centers on (PIL.getbbox(text).w / 2, (asc+desc)/2). - # uilib.misc.get_text_size matches PIL getbbox semantics. Use int() - # (floor for positive operands) — not round() — because PIL's BASIC - # layout effectively floors the fractional pen position; Python's - # banker's rounding on .5 boundaries (e.g. 51.5 → 52) would push - # the glyph one pixel right of PIL. from uilib.misc import get_text_size - desc = abs(int(font.get_sized_descender())) - tw, _ = get_text_size(text, font) + desc = abs(int(font.get_sized_descender(size))) + tw, _ = get_text_size(text, font, size) base_dst = (int(x - tw / 2), int(y - (asc + desc) / 2)) else: base_dst = (int(x), int(y)) - # Blit a cached glyph run rather than rasterizing per draw: text is - # re-drawn on every widget refresh, so freetype rasterization otherwise - # dominates the hot paths (meters, readouts, axis labels). The blit - # honors surface.set_clip, which font.render_to does not. - surf = _text_surface(text, font, (color.r, color.g, color.b, color.a)) + surf = _text_surface(text, font, (color.r, color.g, color.b, color.a), size) self.surface.blit(surf, (base_dst[0] - _TEXT_PAD, base_dst[1] - _TEXT_PAD)) def draw_arc_aa(self, cx: int, cy: int, r: int, clip: Box, color: ColorLike) -> None: From 2eccbe3f5809393bb928727595633825dd09414e Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Thu, 20 Aug 2026 23:29:43 -0400 Subject: [PATCH 16/18] Fixes to longpress CCs --- common/contexts.py | 6 ++ common/parameter.py | 10 +++ modalapi/modhandler.py | 115 ++++++++++++++++++++++------ tests/v3/test_midi_learn.py | 67 ++++++++++++++++ tests/v3/test_reactive_parameter.py | 27 +++++++ 5 files changed, 203 insertions(+), 22 deletions(-) diff --git a/common/contexts.py b/common/contexts.py index 547a3c3cf..ad9032386 100644 --- a/common/contexts.py +++ b/common/contexts.py @@ -212,6 +212,12 @@ def add(self, decl: BindingDecl) -> None: key = (decl.control.cls, decl.event_kind) self.rows.setdefault(key, []).append(decl) + def remove(self, should_drop: Callable[[BindingDecl], bool]) -> None: + """Drop every row for which *should_drop* is true, across all buckets — + the mutation counterpart to add(), so callers never touch `rows`.""" + for key, rows in list(self.rows.items()): + self.rows[key] = [d for d in rows if not should_drop(d)] + # Per-class chain: which ContextKinds are consulted, top (highest precedence) # to bottom, for a given ControlClass. NAV is intentionally absent — it is an diff --git a/common/parameter.py b/common/parameter.py index bc0104482..94bb8b577 100644 --- a/common/parameter.py +++ b/common/parameter.py @@ -226,6 +226,16 @@ def commit(self, value: float, sink: ParamSink | None) -> None: return self._notify_settled() + def pulse(self, sink: ParamSink | None) -> None: + """A self-clearing trigger edge (pprops:trigger): drive to the "on" edge, + publish it once through *sink*, then clear to rest — persists no value + and never reverts, so each press is a fresh rising edge.""" + self._set(self.maximum) + if sink is not None: + sink(self) + self._set(self.minimum) + self._notify_settled() + def _set(self, value: float) -> None: if value == self._value: return diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 4fe60981d..5c89a979d 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -135,12 +135,29 @@ STARTUP_REST_BACKOFF_S = (0.25, 0.25, 0.5, 1.0, 2.0) -def _remove_binding_row(layer: ContextLayer, binding_id: str) -> None: +def _remove_binding_row( + layer: ContextLayer, + binding_id: str, + instance_id: str | None = None, + symbol: Symbol | None = None, +) -> None: # Drop any PEDALBOARD-layer row whose control.id matches a learned binding # that's being replaced. Scans all event_kind buckets since a re-learn could - # cross controller classes (footswitch ↔ encoder). - for (cls, event_kind), rows in list(layer.rows.items()): - layer.rows[(cls, event_kind)] = [d for d in rows if d.control.id != binding_id] + # cross controller classes (footswitch ↔ encoder). When the param being + # rebound is known, keep rows at the same CC that target a *different* + # plugin/param, so two instances sharing one CC never evict each other. + def _keep(d: BindingDecl) -> bool: + if d.control.id != binding_id: + return True + if instance_id is not None and symbol is not None: + eff = d.effects[0] if d.effects else None + if isinstance(eff, ParamEffect) and ( + getattr(eff.plugin, "instance_id", None) != instance_id or eff.symbol != symbol + ): + return True + return False + + layer.remove(lambda d: not _keep(d)) class LongpressCcKey(namedtuple("LongpressCcKey", ["channel", "cc"])): @@ -535,21 +552,30 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool: if controller.parameter is not None: controller.parameter.preview(controller.value_for(controller.toggled)) self.update_lcd_fs(footswitch=controller) - case ParamEffect(): - # Footswitch PRESS with a bound plugin param. "on" polarity - # differs by param: :bypass "on" is not-bypassed (0), a plain - # toggle "on" is the max end — value_for encodes both, the - # inverse of Footswitch.set_value. The MIDI CC carries the - # change to mod-host. + case ParamEffect(plugin=eff_plugin, symbol=eff_symbol): + # Resolve the param from the row's own effect, not + # fs.parameter, so two instances sharing a CC stay distinct. if fs is not None: - new_toggled = not fs.toggled - fs.toggled = new_toggled - fs.set_led(new_toggled) - if fs.midi_CC is not None: - self._emit_midi(fs, 127 if new_toggled else 0) - if fs.parameter is not None: - fs.parameter.preview(fs.value_for(new_toggled)) - self.update_lcd_fs(footswitch=fs) + params = getattr(eff_plugin, "parameters", None) + param = params.get(eff_symbol) if params else None + if param is not None and param.is_momentary: + # pprops:trigger (advance): one-shot edge, not a + # toggle — the plugin self-clears the port. + param.pulse(self._sink_for(param, fs)) + self.update_lcd_fs(footswitch=fs) + else: + # Absolute toggle (:bypass, plain toggled params): "on" + # polarity differs by param — value_for encodes both, + # the inverse of Footswitch.set_value. The MIDI CC + # carries the change to mod-host. + new_toggled = not fs.toggled + fs.toggled = new_toggled + fs.set_led(new_toggled) + if fs.midi_CC is not None: + self._emit_midi(fs, 127 if new_toggled else 0) + if param is not None: + param.preview(fs.value_for(new_toggled)) + self.update_lcd_fs(footswitch=fs) case RelayEffect(): if fs is not None: new_toggled = not fs.toggled @@ -558,9 +584,16 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool: fs.set_led(new_toggled) self.update_lcd_fs(bypass_change=True) case RawMidiCcEffect(channel=ch, cc=cc): - key = LongpressCcKey(channel=ch, cc=cc) - on = self._longpress_cc_state[key] = not self._longpress_cc_state.get(key, False) - self._emit_raw_cc(ch, cc, 127 if on else 0) + if self._raw_cc_targets_momentary(ch, cc): + # Longpress mapped to a pprops:trigger port (loopjefe + # reset): a rising-edge one-shot, 127 on every press — + # mirroring the short-press momentary path, no 127/0 + # toggle. mod-ui's echo reconciles the learned plugin. + self._emit_raw_cc(ch, cc, 127) + else: + key = LongpressCcKey(channel=ch, cc=cc) + on = self._longpress_cc_state[key] = not self._longpress_cc_state.get(key, False) + self._emit_raw_cc(ch, cc, 127 if on else 0) case PedalboardEffect(direction=direction): if direction == "DOWN": self.previous_pedalboard() @@ -573,6 +606,22 @@ def _emit_raw_cc(self, channel: int, cc: int, value: int) -> None: controller.midi_CC guard; virtual out only.""" self.hardware.midiout.send_message([channel | CONTROL_CHANGE, cc, int(value)]) + def _raw_cc_targets_momentary(self, channel: int, cc: int) -> bool: + """True when a loaded plugin parameter is MIDI-bound to (channel, cc) + and is a momentary pprops:trigger port. Longpress raw-CC actions default + to a 127/0 toggle, but a trigger target (e.g. loopjefe reset) needs a + one-shot rising edge, mirroring the short-press momentary path.""" + if self._current is None: + return False + binding = f"{channel}:{cc}" + for plugin in self.current.pedalboard.plugins: + if plugin.parameters is None: + continue + for param in plugin.parameters.values(): + if param.binding == binding and param.is_momentary: + return True + 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: @@ -1389,6 +1438,10 @@ def _sink_for(self, param: Parameter, controller: Controller | None = None) -> P enc = enc if isinstance(enc, EncoderController) else None if enc is not None and enc.midi_CC is not None: return functools.partial(self._publish_cc, enc) + if isinstance(controller, Footswitch) and controller.midi_CC is not None: + # A footswitch rides its own CC (pulse for a trigger, toggle + # otherwise), not the param_set an unheld param takes. + return functools.partial(self._publish_fs_cc, controller) if param.instance_id in (ExternalMidi.EXTERNAL_INSTANCE_ID, Pedalboard.TRANSPORT_INSTANCE_ID): return None return self._publish_plugin_param @@ -1407,6 +1460,16 @@ def _publish_cc(self, controller: EncoderController, param: Parameter) -> bool: self._emit_midi(controller, controller.to_midi(param.value)) return True + def _publish_fs_cc(self, fs: Footswitch, param: Parameter) -> bool: + """Emit a footswitch-bound param as its binary CC: the "on" edge (max) + sends 127, rest 0. A trigger pulse holds max for the send, so it fires + one 127 then self-clears.""" + if fs.midi_CC is None: + return False + hi = param.maximum if param.maximum is not None else 1.0 + self._emit_midi(fs, 127 if param.value >= hi else 0) + return True + def _publish_plugin_param(self, param: Parameter) -> bool: if self._is_pedalboard_loading or self.ws_bridge is None or param.instance_id is None: return False @@ -1424,11 +1487,19 @@ def _add_learned_binding_row( ) -> None: layer = self._controller_manager.effective_table.layers[0] if old_binding is not None: - _remove_binding_row(layer, old_binding) + _remove_binding_row(layer, old_binding, plugin.instance_id, param.symbol) if controller is None: return if isinstance(controller, Footswitch): cls, event_kind = ControlClass.FOOTSWITCH, EventKind.PRESS + # Drop the bare CC-toggle PRESS row _bind_footswitch_actions added + # at this key; inserted first, it would shadow this ParamEffect. + assert param.binding is not None + layer.remove( + lambda d: d.control.id == param.binding + and bool(d.effects) + and isinstance(d.effects[0], MidiCcEffect) + ) else: cls, event_kind = ControlClass.ANALOG, EventKind.ROTATE assert param.binding is not None diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 726c94219..2b80bc793 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -354,6 +354,73 @@ def test_midi_learn_binds_trigger_parameter_as_momentary(self, v3_system: System "port type, with zero loopjefe-specific input code" ) + def test_momentary_press_emits_one_shot_127_every_press( + self, v3_system: SystemFixture, make_parameter + ): + """A pprops:trigger port fires on a rising edge only (loopjefe self- + clears the port). So every short-press must emit a fresh 127 — never + the 127/0 alternation a latching toggle produces, which would make the + looper advance on only every other press.""" + from rtmidi.midiconstants import CONTROL_CHANGE + from pistomp.input.event import SwitchEvent, SwitchEventKind + + handler = v3_system.handler + hw = v3_system.hw + ws_bridge = v3_system.ws_bridge + assert handler.current + + fs0 = hw.footswitches[0] + channel, cc = _binding_for(hw, fs0).split(":") + plugin = _make_loopjefe_plugin_with_advance(make_parameter) + handler.current.pedalboard.plugins = [plugin] + ws_bridge.inject(f"midi_map /graph/loopjefe advance {channel} {cc} 0.0 1.0") + handler.poll_ws_messages() + + hw.midiout.send_message.reset_mock() + for _ in range(3): + handler.handle(SwitchEvent(controller=fs0, kind=SwitchEventKind.PRESS, timestamp=1.0)) + + sent = [c.args[0][2] for c in hw.midiout.send_message.call_args_list] + assert sent == [127, 127, 127], "momentary trigger must one-shot 127, not toggle 127/0" + assert all(c.args[0][1] == int(cc) for c in hw.midiout.send_message.call_args_list) + assert fs0.toggled is False, "a trigger has no on/off state to latch" + + def test_momentary_longpress_reset_emits_one_shot_127( + self, v3_system: SystemFixture, make_parameter + ): + """A longpress raw-CC mapped to a pprops:trigger port (loopjefe reset) + is a one-shot too: every longpress emits 127, not the 127/0 toggle the + raw-CC path uses for ordinary (non-trigger) targets.""" + from rtmidi.midiconstants import CONTROL_CHANGE + from common.parameter import Type + from pistomp.input.event import SwitchEvent, SwitchEventKind + + handler = v3_system.handler + hw = v3_system.hw + assert handler.current + + fs0 = hw.footswitches[0] + reset_cc = 64 + plugin = _make_loopjefe_plugin_with_advance(make_parameter) + reset = make_parameter("reset", "loopjefe", value=0.0) + reset.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl + reset.binding = f"{fs0.midi_channel}:{reset_cc}" # pedalboard-learned CC + plugin.parameters[Symbol("reset")] = reset + handler.current.pedalboard.plugins = [plugin] + + fs0.longpress_action = {"midi_CC": reset_cc} + handler.bind_current_pedalboard() + + hw.midiout.send_message.reset_mock() + event = SwitchEvent(controller=fs0, kind=SwitchEventKind.LONGPRESS, timestamp=1.0) + for _ in range(3): + handler.handle(event) + + expected = [fs0.midi_channel | CONTROL_CHANGE, reset_cc, 127] + assert all(c.args[0] == expected for c in hw.midiout.send_message.call_args_list), ( + "reset is pprops:trigger — every longpress must emit 127, not toggle 127/0" + ) + def test_update_interesting_outputs_derives_from_plugin_led_spec( self, v3_system: SystemFixture, make_parameter ): diff --git a/tests/v3/test_reactive_parameter.py b/tests/v3/test_reactive_parameter.py index 127708090..7d56fcfa6 100644 --- a/tests/v3/test_reactive_parameter.py +++ b/tests/v3/test_reactive_parameter.py @@ -197,6 +197,33 @@ def test_settled_fires_on_reconcile_and_commit_not_preview(): assert settled == [130.0, 140.0] # rolled back — did not settle +def test_pulse_emits_one_edge_and_self_clears(): + """A pprops:trigger pulse drives the value to its "on" edge, publishes that + single edge through the sink, then self-clears to rest — a footswitch CC + reads 127 once and the port never latches.""" + info: PortInfo = {"shortName": "adv", "symbol": "advance", "ranges": {"minimum": 0, "maximum": 1}} + p = Parameter(info, 0.0, None, "inst") + sent: list[float] = [] + p.pulse(lambda param: sent.append(param.value) or True) + + assert sent == [1.0], "the sink sees the held 'on' edge" + assert p.value == 0.0, "a trigger persists no value — it self-clears to rest" + + +def test_pulse_settles_at_rest_not_at_the_edge(): + """subscribe_settled fires once, at the cleared rest value — a bound + footswitch keycap that mirrors settled state never latches 'on'.""" + info: PortInfo = {"shortName": "adv", "symbol": "advance", "ranges": {"minimum": 0, "maximum": 1}} + p = Parameter(info, 0.0, None, "inst") + settled: list[float] = [] + p.subscribe_settled(lambda param: settled.append(param.value)) + + p.pulse(lambda param: True) + p.pulse(lambda param: True) + + assert settled == [0.0, 0.0], "each pulse settles once, at rest" + + def test_subscribe_returns_unsubscriber(): """The returned callable tears down the subscription.""" info: PortInfo = {"shortName": "x", "symbol": "x", "ranges": {"minimum": 0, "maximum": 1}} From b9e69e1c236312be99dc59d80d16de7256a0eb61 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 21 Aug 2026 00:30:28 -0400 Subject: [PATCH 17/18] SImplified momentary switches --- common/contexts.py | 2 +- modalapi/modhandler.py | 45 ++++++++++++++++++++++++------------- tests/v3/test_midi_learn.py | 38 +++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 16 deletions(-) diff --git a/common/contexts.py b/common/contexts.py index ad9032386..185f4995d 100644 --- a/common/contexts.py +++ b/common/contexts.py @@ -98,7 +98,7 @@ class Effect: class ParamEffect(Effect): plugin: object # PluginRef, resolved at pedalboard load symbol: Union[Symbol, type[SelectionSymbol]] - commit: bool = True # WebSocket send_parameter on fire + commit: bool = True # Declared: WebSocket send on fire. Dispatch does not read it. A no-op today. mirror: bool = True # reconcile from inbound param_set echo diff --git a/modalapi/modhandler.py b/modalapi/modhandler.py index 5c89a979d..3cea9faf9 100755 --- a/modalapi/modhandler.py +++ b/modalapi/modhandler.py @@ -584,13 +584,21 @@ def _fire_row(self, decl: BindingDecl, event: ControllerEvent) -> bool: fs.set_led(new_toggled) self.update_lcd_fs(bypass_change=True) case RawMidiCcEffect(channel=ch, cc=cc): - if self._raw_cc_targets_momentary(ch, cc): - # Longpress mapped to a pprops:trigger port (loopjefe - # reset): a rising-edge one-shot, 127 on every press — - # mirroring the short-press momentary path, no 127/0 - # toggle. mod-ui's echo reconciles the learned plugin. - self._emit_raw_cc(ch, cc, 127) + param = self._param_bound_to_cc(ch, cc) + sink = functools.partial(self._publish_raw_cc, ch, cc) + if param is not None and param.is_momentary: + # Trigger target (loopjefe reset): one-shot edge on the + # param's own bound CC. Resolved at fire time, so a live + # re-learn needs no longpress-row patch. + param.pulse(sink) + elif param is not None: + # Loaded toggle target: flip through the reactive layer. + lo = param.minimum if param.minimum is not None else 0.0 + hi = param.maximum if param.maximum is not None else 1.0 + edge = lo if param.value >= (lo + hi) / 2 else hi + param.commit(edge, sink) else: + # Orphan CC (no loaded param): local 127/0 toggle. key = LongpressCcKey(channel=ch, cc=cc) on = self._longpress_cc_state[key] = not self._longpress_cc_state.get(key, False) self._emit_raw_cc(ch, cc, 127 if on else 0) @@ -606,21 +614,28 @@ def _emit_raw_cc(self, channel: int, cc: int, value: int) -> None: controller.midi_CC guard; virtual out only.""" self.hardware.midiout.send_message([channel | CONTROL_CHANGE, cc, int(value)]) - def _raw_cc_targets_momentary(self, channel: int, cc: int) -> bool: - """True when a loaded plugin parameter is MIDI-bound to (channel, cc) - and is a momentary pprops:trigger port. Longpress raw-CC actions default - to a 127/0 toggle, but a trigger target (e.g. loopjefe reset) needs a - one-shot rising edge, mirroring the short-press momentary path.""" + def _param_bound_to_cc(self, channel: int, cc: int) -> Parameter | None: + """The loaded plugin parameter MIDI-bound to (channel, cc), or None for + an orphan CC. Resolved at fire time so a live re-learn is reflected with + no longpress-row patch — the CC is a free reference, not a controller.""" if self._current is None: - return False + return None binding = f"{channel}:{cc}" for plugin in self.current.pedalboard.plugins: if plugin.parameters is None: continue for param in plugin.parameters.values(): - if param.binding == binding and param.is_momentary: - return True - return False + if param.binding == binding: + return param + return None + + def _publish_raw_cc(self, channel: int, cc: int, param: Parameter) -> bool: + """Emit a resolved param's binary CC on its own (channel, cc): the "on" + edge (max) sends 127, rest 0, with no owning controller. pulse and commit + ride this so a longpress reaches mod-host on the param's bound CC.""" + hi = param.maximum if param.maximum is not None else 1.0 + self._emit_raw_cc(channel, cc, 127 if param.value >= hi else 0) + return True def _emit_midi(self, controller, midi_value: int) -> None: """Send a CC. Tries the external port if routed; falls back to virtual.""" diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 2b80bc793..525dfb2e5 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -421,6 +421,44 @@ def test_momentary_longpress_reset_emits_one_shot_127( "reset is pprops:trigger — every longpress must emit 127, not toggle 127/0" ) + def test_longpress_toggle_target_flips_through_reactive_layer( + self, v3_system: SystemFixture, make_parameter + ): + """A longpress raw-CC resolving to a loaded *non*-trigger param toggles + it through the reactive layer: each longpress flips the param and emits + its bound CC as an alternating 127/0 edge — no local _longpress_cc_state, + so the toggle tracks the param's real value.""" + from rtmidi.midiconstants import CONTROL_CHANGE + from pistomp.input.event import SwitchEvent, SwitchEventKind + + handler = v3_system.handler + hw = v3_system.hw + assert handler.current + + fs0 = hw.footswitches[0] + toggle_cc = 64 + plugin = _make_loopjefe_plugin_with_advance(make_parameter) + solo = make_parameter("solo", "loopjefe", value=0.0) # non-trigger toggle + solo.binding = f"{fs0.midi_channel}:{toggle_cc}" + plugin.parameters[Symbol("solo")] = solo + handler.current.pedalboard.plugins = [plugin] + + fs0.longpress_action = {"midi_CC": toggle_cc} + handler.bind_current_pedalboard() + + hw.midiout.send_message.reset_mock() + event = SwitchEvent(controller=fs0, kind=SwitchEventKind.LONGPRESS, timestamp=1.0) + for _ in range(2): + handler.handle(event) + + sent = [c.args[0][2] for c in hw.midiout.send_message.call_args_list] + assert sent == [127, 0], "a loaded toggle target alternates 127/0 on its bound CC" + assert all( + c.args[0][:2] == [fs0.midi_channel | CONTROL_CHANGE, toggle_cc] + for c in hw.midiout.send_message.call_args_list + ) + assert solo.value == 0.0, "two flips return the param to rest" + def test_update_interesting_outputs_derives_from_plugin_led_spec( self, v3_system: SystemFixture, make_parameter ): From efca0ab85abcd9b9583590787ca9285c610aa518 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Fri, 21 Aug 2026 00:36:32 -0400 Subject: [PATCH 18/18] Parse full URIs for parameters to extract metadata properly --- common/parameter.py | 22 ++++++++++++------ tests/v3/test_midi_learn.py | 46 +++++++++++++++++++++++++++++-------- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/common/parameter.py b/common/parameter.py index 94bb8b577..1b32052a1 100644 --- a/common/parameter.py +++ b/common/parameter.py @@ -111,6 +111,14 @@ def is_hidden_port(plugin_info: PortInfo) -> bool: return plugin_info.get("designation", "") in HIDDEN_DESIGNATIONS +def _has_property(properties: list[str], name: str) -> bool: + """Match MOD-UI's short property names and raw LV2 URIs.""" + return any( + property_name == name or property_name.rsplit("#", 1)[-1].rsplit("/", 1)[-1] == name + for property_name in properties + ) + + class Type(Enum): DEFAULT = 0 # No explicitly defined type (eg. linear float) ENUMERATION = 1 @@ -173,20 +181,20 @@ def __init__( properties = plugin_info.get("properties") or [] if len(properties) > 0: - if TTL_LOGARITHMIC in properties: + if _has_property(properties, TTL_LOGARITHMIC): self.is_logarithmic = True - if TTL_TRIGGER in properties: + if _has_property(properties, TTL_TRIGGER): self.type = Type.TRIGGER - elif TTL_ENUMERATION in properties: + elif _has_property(properties, TTL_ENUMERATION): self.enum_values = plugin_info.get("scalePoints") or [] self.type = Type.ENUMERATION - elif TTL_INTEGER in properties: + elif _has_property(properties, TTL_INTEGER): self.type = Type.INTEGER - elif TTL_LOGARITHMIC in properties: + elif _has_property(properties, TTL_LOGARITHMIC): self.type = Type.LOGARITHMIC - elif TTL_TAPTEMPO in properties: + elif _has_property(properties, TTL_TAPTEMPO): self.type = Type.TAPTEMPO - elif TTL_TOGGLED in properties: + elif _has_property(properties, TTL_TOGGLED): self.type = Type.TOGGLED @property diff --git a/tests/v3/test_midi_learn.py b/tests/v3/test_midi_learn.py index 525dfb2e5..e2d03a0e5 100644 --- a/tests/v3/test_midi_learn.py +++ b/tests/v3/test_midi_learn.py @@ -14,6 +14,26 @@ } +def test_trigger_uri_property_is_momentary(): + """Raw LV2 property URIs still identify LoopJefe trigger ports.""" + parameter = Parameter( + { + "symbol": "advance", + "shortName": "Advance", + "ranges": {"minimum": 0.0, "maximum": 1.0}, + "properties": [ + "http://lv2plug.in/ns/lv2core#integer", + "http://lv2plug.in/ns/ext/port-props#trigger", + ], + }, + 0.0, + "0:60", + "loopjefe_1", + ) + + assert parameter.is_momentary is True + + def _binding_for(hw, controller): """The 'channel:cc' key under which a controller is registered.""" return next(k for k, v in hw.controllers.items() if v is controller) @@ -308,20 +328,28 @@ def test_v3_midi_learn_unknown_instance_is_ignored(v3_system: SystemFixture, mak assert plugin.has_footswitch is False -def _make_loopjefe_plugin_with_advance(make_parameter, instance_id="loopjefe"): - """Build 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 Symbol, Type +def _make_loopjefe_plugin_with_advance(_make_parameter, instance_id="loopjefe"): + """Build LoopJefe from the same LV2 trigger metadata mod-ui returns.""" from modalapi.plugin import Plugin from plugins import lookup from plugins.loopjefe import LOOPJEFE_URIS - advance = make_parameter("advance", instance_id, value=0.0) - advance.type = Type.TRIGGER # pprops:trigger in loopjefe.ttl + advance = Parameter( + { + "symbol": "advance", + "shortName": "Advance", + "ranges": {"minimum": 0.0, "maximum": 1.0}, + "properties": [ + "http://lv2plug.in/ns/lv2core#integer", + "http://lv2plug.in/ns/ext/port-props#trigger", + ], + }, + 0.0, + None, + instance_id, + ) uri = LOOPJEFE_URIS[0] - return Plugin(instance_id, {Symbol("advance"): advance}, {}, "Looper", - uri=uri, customization=lookup(uri)) + return Plugin(instance_id, {Symbol("advance"): advance}, {}, "Looper", uri=uri, customization=lookup(uri)) class TestMidiLearnBindsMomentaryAndOutputs: