Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Changed
- Removed the tap tempo REST fallback, as we now expect the websocket bridge to always be available.
### Fixed
- The parameter dialog on the LCD sometimes did not change values due to a race condition with respect to MOD-UI's `last.json`. pi-Stomp then did not send parameter changes to MOD-UI until you selected a different pedalboard. Parameters on a knob or an encoder continued to work, because they send MIDI CC.
- The LCD showed a bypass that MOD-UI did not receive, if you tapped it while a pedalboard loaded. The LCD now keeps the last value that MOD-UI confirmed.
- A parameter change from a plugin menu could be lost if the connection to MOD-UI was busy. pi-Stomp now sends the value again on the next cycle.
- The bypass button in a plugin menu did not agree with the footswitch LED, if the plugin had a footswitch. Every bypass is now one action, and the LED follows it.
- Turning an encoder or expression pedal bound to a parameter while a pedalboard loads no longer leaves the LCD on a value the audio never took: MIDI-CC sends are held back during the load, the same way parameter sends over the WebSocket already were, and the edit reverts on screen.
- An edit that MOD-UI cannot take — the connection is down, or a pedalboard load is in progress — now reverts on the LCD immediately instead of being shown as applied and retried later. A retry of an edit made during a load could deliver the old value after the switch, onto the newly loaded pedalboard.
- An expression pedal or knob bound to a parameter now moves the parameter the way a bound encoder does: the value is confirmed only when the send leaves, and the LCD bar shows the parameter's value instead of the raw pedal position, which could disagree with the audio on a logarithmic or stepped parameter.
- Turning a knob quickly no longer repaints the LCD once per echoed value: the parameter echoes of one poll cycle collapse to the last value per parameter, so a fast tweak paints once.

## [v3.3.1] - 2026-09-01
### Added
Expand Down
118 changes: 45 additions & 73 deletions GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,14 @@ Architecture reference: `docs/architecture.md`. Subsystem detail:
`pistomp/input/README.md` (input dispatch), `uilib/README.md` (paint system).
Wire protocol: `../pistomp-manual/src/developers/websocket-bridge.md` (message table,
bypass paths); `../pistomp-manual/src/plugins/choosing-pedals/build.md` (REST add/connect/save).
**Read the code before trusting any doc, including this one.**
**Read the code before trusting any doc, excluding this one.**

## Agent behaviour

As the user, I expect you to lead with suggestions and uncover facts; I own the architecture and judgment.
As the user, I expect you to lead with suggestions and uncover facts. I hold that context; you do not. An implementation built on a guess is expensive to me: I have to find the guess, see where it left the intent, and unwind it. A suggestion costs one read and a "no." So lead with suggestions and uncover facts; I own the architecture and judgment.

1. **Suggest, with justification** — prior art, real hardware/ecosystem examples, ways this lets players express themselves. A suggestion I can reject beats an implementation I have to unwind.
2. **When the design space is open, hand it back as a question.** Decompose it into its principal axes and ask with the multi-select tool, not as prose options. *Open* means more than one defensible architecture, or a choice that's expensive to reverse. A bug fix or an already-constrained detail is not open — just do it.
2. **When the design space is open, hand it back as a question.** Decompose it into its principal axes and ask with the multi-select tool, not as prose options. *Open* means more than one defensible architecture, or a choice that's expensive to reverse. A bug fix or an already-constrained detail is not open — just do it. (Debounce constant for a new encoder: constrained, do it. Whether encoders map to parameter pages at all: open, ask.)
3. **I own the scaffolding.** Once my choices constrain the space, fill in the rest. That's where you accelerate me.

Don't correct me on things that aren't germane, especially when you're only guessing I don't understand. Do tell me when I'm wrong about the thing at hand.
Expand All @@ -24,12 +24,12 @@ Don't correct me on things that aren't germane, especially when you're only gues

- **pyright zero.** No new errors, ever.
- **No broad `# pyright: ignore`.** A blanket ignore is a bug you haven't found yet.
- **`getattr` / `hasattr` are banned.** If you reach for them, the type is wrong.
- **`getattr` / `hasattr` are banned.** If you reach for them, the type is wrong — lean on the annotations and `cast` where you must. A dynamic attribute is a typed protocol you haven't written yet.
- **Dependencies form a DAG.** No cycles between modules.
- **MOD-UI is the single writer** of bypass and parameter state. We emit, paint
optimistically, and reconcile against its echo. Never treat local state as truth.
- **No comments explaining course corrections.**
- **Production python files must always have AGPL headers.**
- **Production python files must always have AGPL headers.** Copy the SPDX block from any of them, e.g. `pistomp/adcswitch.py`.
- **NAV is unhijackable.** Rotate/click/longpress on the NAV control always operates on
the current selection. No panel or binding may consume a raw NAV event, and no
`declare_bindings()` row may name `cls=NAV` — it's the one axiom the precedence
Expand Down Expand Up @@ -59,13 +59,11 @@ observability cruft. If a comment explains *what*, delete it and fix the name in

Same for prose: answer the question, skip the preamble.

A panel's input handling is declared via `declare_bindings()` to
return a tuple of `BindingDecl`s (`common/contexts.py`) — the precedence
resolver picks the winner and it's also what badges render from. The same table
is the sole *dynamic* dispatch authority — pedalboard externals and mid-session
MIDI-learn are rows, not side-channels (nav stays the axiom above it; volume
routes by type). Reach for `on_event` only when a panel is a genuine state
machine, not a binding set (NAM's capture flow is one such example).
A panel declares its input handling with `declare_bindings()` → `BindingDecl`s
(`common/contexts.py`); the precedence resolver picks the winner and badges render off the
same table. Two things the source won't tell you: nav stays the axiom above the resolver,
and `on_event` is only for a panel that is a genuine state machine (NAM's capture flow), not
a binding set.

## Commands

Expand All @@ -80,72 +78,58 @@ ssh pistomp@pistomp.local "journalctl -u mod-ala-pi-stomp -f" # live logs
```

Deploy by `scp` + `ps-restart` on the device or by `./deploy.sh`; source lives at
`/home/pistomp/pi-stomp/`. Shipping a release requires a version bump in the
*separate* `pi-gen-pistomp` repo — see `docs/architecture.md`.
`/home/pistomp/pi-stomp/`. Shipping a release needs a version bump in the `pi-gen-pistomp`
repo, which is not in this checkout — see `docs/architecture.md`.

The system python provides base packages (`python3-lilv`); PyPI deps live in a
uv-managed venv. Don't try to pip-install the system ones.

## Traps

- **Never create a bare `pygame.Surface((w, h))`.** It inherits the display format —
opaque RGB when headless (device/tests) but ARGB under a real window driver (the
cocoa emulator). The stray alpha silently breaks SRCALPHA compositing; glyph pastes
drop their fill and you will blame the wrong thing. Be explicit: `pygame.SRCALPHA`
for alpha, or `depth=32, masks=(0xFF0000, 0xFF00, 0xFF, 0)` for opaque RGB
- **Never create a bare `pygame.Surface((w, h))`.** It inherits the display format — opaque
RGB when headless (device/tests), ARGB under a real window driver (the cocoa emulator) —
and the stray alpha breaks SRCALPHA compositing. Be explicit: `pygame.SRCALPHA` for alpha,
or the opaque 32-bit `masks=` construction in `uilib/container.py` for a blend destination
(bit-identical to the device; `depth=24` differs in AA rounding).

- **`PanelStack`'s root surface must stay opaque.** `LcdIli9341.update` quantises it to
RGB565 with an SDL convert-blit; give that blit an `SRCALPHA` source and SDL silently
switches to its per-pixel *alpha-blending* blitter — ~7x slower, and it lands on every
LCD push. The root is a blend *destination*, so it needs 32-bit for blend precision
but gains nothing from a dest alpha channel (a dimmer over black yields the same
`(128,128,128)` either way). Panel surfaces (`ShroudedPanel`, `RoundedPanel`) are blit
*sources* and do still need `RGBA`. Benchmark the pack path with
`tools/bench_pack_variants.py`.
RGB565 with a convert-blit; an `SRCALPHA` source flips SDL to its per-pixel
alpha-blending blitter — ~7x slower, on every LCD push. The root is a blend
*destination*: it needs 32-bit for blend precision but no dest alpha channel. Panel
surfaces (`ShroudedPanel`, `RoundedPanel`) are blit *sources* and do need `RGBA`.
Benchmark the pack path with `tools/bench_pack_variants.py`.

- **Snapshot loads broadcast only deltas** against mod-ui's own cache; pedalboard loads
and connect dumps rebroadcast unconditionally. Reselecting a *board* is a full
resync. Reselecting a *snapshot* is not.

- **A UI bypass of a footswitch-less plugin gets no echo.** mod-ui skips the origin
socket, and mod-host emits no `param_set` for bypasses it received from mod-ui. So
that path must update local state itself: `Plugin.toggle_bypass` commits, which
writes and publishes as one act and reverts if the send never leaves. A
footswitch-bound plugin is the opposite: `_sink_for` routes its commit out as MIDI
CC → mod-host → feedback echo, and that echo reconciles it. The asymmetry is one of
*transport*, chosen by `_sink_for`, and it is deliberate.

Dispatch carries no such fork. Every UI bypass — LCD tile, plugin panel button — is
one commit on `:bypass`, and the keycap follows because `StatefulController`
subscribes to the settled value. Never reach the wire by faking a press: the row
that wins that switch need not be the bypass. The press is its own path — a preview
plus the emit in `_fire_row`'s `ParamEffect` arm — because it already knows its
transport and has no sink to choose.

- **A switch's CC carries only the two ends of the binding range.** mod-ui's advanced
MIDI-learn menu puts a footswitch on a continuous parameter with its own min/max,
and a press alternates between exactly those. A UI edit that lands *between* them
has no CC code, so `_publish_switch_cc` sends it over the WebSocket instead — else
mod-host answers an endpoint against a screen showing the real value. Pinned by the
endpoint pair in `tests/v3/test_sink_routing.py`.

- **`loading_start` opens a window that suppresses outbound sends; `loading_end`
closes it.** Both come from mod-ui, in pairs, from a board load and from the
connect dump alike. Nothing else may raise `_is_pedalboard_loading` — a window
raised where nothing closes it silently refuses every parameter send for the rest
of the session, and `commit` then rolls each edit back on screen.
`set_current_pedalboard` clears it as the point we have caught up, which also
covers the one case mod-ui abandons its own window (an aborted load returns before
`loading_end`).
- **A UI bypass of a footswitch-less plugin gets no echo.** mod-ui skips the origin socket,
and mod-host emits no `param_set` for a bypass it received from mod-ui — so that path must
update local state itself (`Plugin.toggle_bypass` commits). A footswitch-bound plugin is
the opposite: `_sink_for` routes the commit out as MIDI CC and the echo reconciles it. The
asymmetry is one of *transport*, chosen by `_sink_for`, and it is deliberate. (Dispatch
carries no such fork; never fake a press to reach the wire — see `_fire_row`.)

- **A switch's CC carries only the two ends of the binding range.** A press alternates
between exactly the min/max mod-ui's advanced MIDI-learn assigned. A UI edit that lands
*between* them has no CC code, so `_publish_switch_cc` sends it over the WebSocket
instead — else mod-host answers an endpoint against a screen showing the real value.
Pinned by the endpoint pair in `tests/v3/test_sink_routing.py`.

- **`loading_start` opens a window that suppresses outbound sends; `loading_end` closes
it.** Both come from mod-ui in pairs, from a board load and a connect dump alike. Nothing
else may raise `_is_pedalboard_loading` — an unclosed window silently refuses every send
for the rest of the session, and `commit` then rolls each edit back on screen.
`set_current_pedalboard` also clears it, covering the aborted load that returns before
`loading_end`.

- **Send form and echo form differ.** We send `param_set /graph/{id}/{sym} {v}`; both broadcast paths come back as `param_set /graph/{id} {sym} {v:%f}`

- **Never extract `lv2plugins.tar.gz` whole.** It's huge. Pull single files with
`tar --to-stdout`. Prefer inspecting the live device anyway.

- **Blocking subprocess calls (nmcli, systemctl) must not run on the UI thread.** They
stall the 10ms loop. Use a worker thread and poll-drain the result.
stall the polling loop. Use a worker thread and poll-drain the result.

## Tests

Expand All @@ -162,19 +146,7 @@ On a snapshot mismatch: **fix real failures first.** When only snapshot differen

## Create an LCD screen capture

It's often useful to a create screen capture of the current LCD for documentation, debugging, etc.

On the pi-Stomp with all services running, execute the following:

```bash
ps-record-lcd --still
```
That writes a date-stamped file named: ~/pistomp_capture_YYYYMMDD_HHMMSS.png

To alternatively specify the filename:
```bash
ps-record-lcd --still -o FILE-PATH
```

`ps-record-lcd` is a PATH symlink to `util/record_lcd.py`, installed by the image
(`stage2/05-pistomp/02-run.sh`). Drop `--still` to record video to .mp4 instead.
On the device with services running, `ps-record-lcd --still` writes
`~/pistomp_capture_YYYYMMDD_HHMMSS.png`; `-o FILE` overrides the name, and dropping `--still`
records `.mp4` instead. It's a PATH symlink to `util/record_lcd.py`, installed by the
`pi-gen-pistomp` image build (not this checkout).
12 changes: 6 additions & 6 deletions blend/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,12 +135,17 @@ def deactivate(self) -> None:
if not self.input_controller:
return

self._clear_ws_queue()
self.input_controller.detach_from_input()
if self.parameter_setter:
self.parameter_setter.reset_tracking()
logging.info(f"Deactivated blend mode: '{self.config.get('name')}'")

def sync_current_position(self) -> None:
if self.input_controller is None or self.parameter_setter is None:
return
self.parameter_setter.reset_tracking()
self.input_controller.sync_current_position()

def cleanup(self) -> None:
"""Full teardown (pedalboard unload or re-prepare). Idempotent."""
if self.input_controller is None:
Expand Down Expand Up @@ -190,11 +195,6 @@ def intercept(self, event: ControllerEvent) -> bool:

# ----------------------------------------------------------------- helpers

def _clear_ws_queue(self) -> None:
cleared = self.handler.ws_bridge.clear_queue()
if cleared > 0:
logging.debug(f"Cleared {cleared} pending WebSocket messages")

def _extract_midi_bound_parameters(self) -> MidiBoundParams:
"""Collect (instance_id, symbol) for every MIDI-bound parameter on the current pedalboard."""
assert self.handler.current is not None
Expand Down
5 changes: 3 additions & 2 deletions blend/parameter_setter.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,8 @@ def send_parameter(self, instance_id: str, symbol: Symbol, value: float) -> bool
This prevents flooding the WebSocket with redundant messages during smooth
pedal movements.

Returns True if message was sent, False if skipped due de-duplication or backpressure.
Returns True if message was sent, False if skipped due de-duplication, or refused
because the bridge has no connection.
"""
key = ParameterKey(instance_id, symbol)
last_value = self.last_sent_midi_values.get(key)
Expand All @@ -57,7 +58,7 @@ def send_parameter(self, instance_id: str, symbol: Symbol, value: float) -> bool
self.last_sent_midi_values[key] = value
return True

logging.warning(f"Dropped (backpressure): {instance_id}/{symbol} value={value:.3f}")
logging.warning(f"Dropped (not connected): {instance_id}/{symbol} value={value:.3f}")
return False

def reset_tracking(self) -> None:
Expand Down
1 change: 0 additions & 1 deletion blend/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ def get_normalized_value(self) -> float: ...

class WebSocketBridgeProtocol(Protocol):
def send_parameter(self, instance_id: InstanceId, symbol: Symbol, value: float) -> bool: ...
def clear_queue(self) -> int: ...


SnapshotStateDict: TypeAlias = dict[InstanceId, dict[Symbol, float]]
Expand Down
Loading