From 83ad38e769abf23d180799c3fba9492bace5d788 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 13 Jun 2026 13:11:44 -0400 Subject: [PATCH 01/40] Don't crash param_set / bypass if params don't exist (safety) --- docs/output-data-flow.md | 96 ++++++++++++++++++++++++++++++++++++++++ mod/host.py | 16 +++++-- mod/session.py | 20 +++++++++ 3 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 docs/output-data-flow.md diff --git a/docs/output-data-flow.md b/docs/output-data-flow.md new file mode 100644 index 000000000..2d9a0d128 --- /dev/null +++ b/docs/output-data-flow.md @@ -0,0 +1,96 @@ +# mod-host Output Data Flow + +## Overview + +mod-host continuously produces two kinds of feedback on its read socket (port 5556): + +- **`output_set`** — real-time meter/scope values (audio level, parameter outputs) +- **`param_set`** — state changes: bypass toggles, parameter edits, MIDI-learn assignments + +These share the same socket and the same flow-control mechanism. + +## The `data_finish` / `output_data_ready` Handshake + +mod-host batches one frame of output data, then signals the end: + +``` +mod-host mod-ui (host.py) WebSocket clients + | | | + |-- output_set inst A ------>| | + |-- output_set inst B ------>|-- output_set inst A --------->| + |-- param_set X :bypass 1 ->|-- output_set inst B --------->| + |-- data_finish ------------>|-- param_set X :bypass 1.0 --->| + | | | + | |-- data_ready N -------------->| + | | | + | |<-- data_ready N --------------| + | | (browser echoes back) | + |<-- output_data_ready ------| | + | | | + | (next frame unlocked) | | + |-- output_set inst A ------>| | + ... +``` + +**`output_data_ready` is the unlock signal.** Until mod-ui sends it back to mod-host, +mod-host will not produce the next frame. This means `param_set :bypass` echoes (and +all other feedback) stall if `output_data_ready` is never sent. + +### mod-ui side (`host.py: process_read_message_body`) + +```python +if msg == "data_finish": + if self.web_connected: # any WebSocket client is open + self.web_data_ready_ok = False + self.web_data_ready_counter += 1 + self.msg_callback("data_ready %i" % counter) + return # wait for client echo + else: + yield gen.Task(self.send_output_data_ready, now) # no client: use timer +``` + +### Client echo path (`session.py: ws_data_ready`) + +```python +def ws_data_ready(self, counter): + if self.host.web_data_ready_counter == counter: + self.host.web_data_ready_ok = True + self.host.send_output_data_ready(None, None) # sends output_data_ready to mod-host +``` + +### `send_output_data_ready` (`host.py`) + +```python +def send_output_data_ready(self, now, callback): + ... + self.send_notmodified("output_data_ready", callback) # write socket → mod-host +``` + +## Local Client Optimization (`_is_local`) + +pi-stomp connects from `127.0.0.1`; browsers connect from LAN IPs. +Local clients don't need meter spam, so `msg_callback` skips `output_set` and +`data_ready` for them (`session.py: websocket_opened` sets `ws._is_local = True`). + +**Problem:** if all connected clients are local, `data_ready` is sent to nobody, +nobody echoes, and `output_data_ready` is never sent to mod-host → everything stalls. + +**Fix (`session.py: msg_callback`):** when `data_ready` was suppressed for every +connected client, self-acknowledge immediately — mirroring what a real browser echo +would do: + +```python +if msg.startswith("data_ready ") and not any_non_local_received: + self.host.web_data_ready_ok = True + self.host.send_output_data_ready(None, None) +``` + +When a browser is also open (LAN IP → not `_is_local`), it receives `data_ready` +normally, echoes back, and the real handshake runs. The self-acknowledge only fires +when pi-stomp is the sole client. + +## Why the Browser Throttles + +The browser JavaScript delays its echo by 25 ms before sending `data_ready` back. +This caps the `output_set` update rate at ~40 fps, preventing DOM update starvation +on pedalboards with many parameters. diff --git a/mod/host.py b/mod/host.py index 289cce581..154017f37 100644 --- a/mod/host.py +++ b/mod/host.py @@ -2678,8 +2678,13 @@ def remove_plugin(self, instance, callback): # Host stuff - plugin values def bypass(self, instance, bypassed, callback): - instance_id = self.mapper.get_id_without_creating(instance) - pluginData = self.plugins[instance_id] + try: + instance_id = self.mapper.get_id_without_creating(instance) + pluginData = self.plugins[instance_id] + except KeyError: + if callback is not None: + callback(False) + return pluginData['bypassed'] = bypassed self.send_modified("bypass %d %d" % (instance_id, int(bypassed)), callback, datatype='boolean') @@ -2695,7 +2700,12 @@ def bypass(self, instance, bypassed, callback): def param_set(self, port, value, callback): instance, symbol = port.rsplit("/", 1) - instance_id = self.mapper.get_id_without_creating(instance) + try: + instance_id = self.mapper.get_id_without_creating(instance) + except KeyError: + if callback is not None: + callback(False) + return try: pluginData = self.plugins[instance_id] diff --git a/mod/session.py b/mod/session.py index cd45ea9dc..ca7a96443 100644 --- a/mod/session.py +++ b/mod/session.py @@ -236,6 +236,11 @@ def web_cv_addressing_plugin_port_remove(self, uri, callback): # A new webbrowser page has been open # We need to cache its socket address and send any msg callbacks to it def websocket_opened(self, ws, callback): + # Pi-stomp connects from localhost; browsers come from LAN IPs. + # Local clients don't need real-time audio meters or data_ready flow control. + if ws.request.remote_ip in ('127.0.0.1', '::1'): + ws._is_local = True + def ready(_): self.websockets.append(ws) self.host.open_connection_if_needed(ws) @@ -412,9 +417,24 @@ def readdress_presets(self, instance, callback): # TODO # Everything after this line is yet to be documented + # See docs/output-data-flow.md for the full data_finish / output_data_ready + # handshake and the _is_local local-client optimization. def msg_callback(self, msg): + is_meter = msg.startswith(("output_set ", "data_ready ")) + any_non_local_received = False for ws in self.websockets: + if is_meter and getattr(ws, '_is_local', False): + continue ws.write_message(msg) + any_non_local_received = True + + # If data_ready was suppressed for every connected client (all are local/privileged), + # mirror what ws_data_ready does: acknowledge and send output_data_ready back to + # mod-host so its feedback loop stays unblocked (bypass param_set echoes flow through + # the same channel and would stall otherwise). + if msg.startswith("data_ready ") and not any_non_local_received: + self.host.web_data_ready_ok = True + self.host.send_output_data_ready(None, None) def msg_callback_broadcast(self, msg, ws2): for ws in self.websockets: From 7b29db7909b5c6479afdea96a3e0bc97e8bfad16 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 23 Jun 2026 21:28:41 -0400 Subject: [PATCH 02/40] Add a guide --- CLAUDE.md | 1 + GEMINI.md | 1 + GUIDE.md | 216 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 218 insertions(+) create mode 120000 CLAUDE.md create mode 120000 GEMINI.md create mode 100644 GUIDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 000000000..fbf31d295 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +GUIDE.md \ No newline at end of file diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 000000000..fbf31d295 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +GUIDE.md \ No newline at end of file diff --git a/GUIDE.md b/GUIDE.md new file mode 100644 index 000000000..7027dc76d --- /dev/null +++ b/GUIDE.md @@ -0,0 +1,216 @@ +# mod-ui — the pi-stomp "ultimate" fork + +> This document is the single source of truth for *this* version of `mod-ui` — the one sitting at `/Users/cam/dev/mod-ui`, on branch `more-fixes`, with the PatchStorage integration. It is meant for Claude, Gemini, and any future coding agent that has to touch this code. + +## What this repo is + +This is a fork of [mod-audio/mod-ui](https://github.com/moddevices/mod-ui), the web UI + control layer for the MOD audio ecosystem. It runs as a Python/Tornado webserver and talks to `mod-host` over a socket. It discovers LV2 plugins and pedalboards via `lilv`, serves an HTML5 pedalboard editor, and pushes real-time state to connected browsers and hardware peers over WebSocket. + +This fork is customized for the **pi-stomp** guitar-pedal hardware platform. It lives as a sibling to `pi-stomp`, `pistomp-arch`, `pi-gen-pistomp`, `pistomp-recovery`, etc. + +### Why "ultimate" + +This branch is the accumulation of several feature/experimental branches that were merged together to make a single "do everything" target: + +- PatchStorage-based plugin/pedalboard store in the web UI +- nested user pedalboard directories (`~/.pedalboards//.pedalboard`) +- output clipping indicator (WebSocket push) with mod-host meter flow-control fixes +- WebSocket BPM/BBT rebroadcast between browser clients +- local WebSocket clients (i.e. pi-stomp) skip meter flow-control and self-acknowledge `output_data_ready` +- hardening around `param_set` / bypass when parameters do not exist + +The user describes this as the **"ultimate version"** — the richest feature set — but some features were discussed and never implemented. Those are documented below under [Features we talked about but did not build](#features-we-talked-about-but-did-not-build). + +## How to build / run locally + +This is a Linux project, but it has been built/tested on macOS for development. The C++ utils library is the only compiled component. + +```bash +# Python deps +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Native utils (lilv + jack + optional alsa) +make -C utils +``` + +To run locally against a MOD Desktop / mod-host instance, set the expected environment variables. A typical local run: + +```bash +export MOD_DEV_ENVIRONMENT=0 +export MOD_DEVICE_WEBSERVER_PORT=18181 +export MOD_HOST_PORT=5555 +export MOD_HOST_FEEDBACK_PORT=5556 +export MOD_DATA_DIR=$HOME/.mod-data +export MOD_USER_PEDALBOARDS_DIR=$MOD_DATA_DIR/.pedalboards +export MOD_USER_FILES_DIR=$MOD_DATA_DIR/user-files +export MOD_KEYS_PATH=$MOD_DATA_DIR/keys/ +export MOD_HTML_DIR=$PWD/html +export MOD_DEFAULT_PEDALBOARD=$PWD/default.pedalboard +export MOD_CLOUD_API=https://api.moddevices.com/v2 +export PATCHSTORAGE_API_URL=https://patchstorage.com/api/beta/patches +export PATCHSTORAGE_PLATFORM_ID=8046 +export PATCHSTORAGE_TARGET_ID=8280 +export JACK_PROMISCUOUS_SERVER=jack +python3 server.py +``` + +On the device these values are injected by the `mod-ui.service` in `pistomp-arch` (see `files/mod-ui.service`). + +## Repo layout + +| Path | Purpose | +|------|---------| +| `mod/` | Core Python server: webserver, session, host logic, settings | +| `mod/host.py` | Plugin/pedalboard host controller; most WS dispatch lives here | +| `mod/session.py` | WebSocket session management, local-vs-remote client handling | +| `mod/webserver.py` | HTTP endpoints and pedalboard/config API | +| `mod/settings.py` | Environment-based defaults (PatchStorage IDs live here) | +| `utils/` | C/C++ helpers compiled into `libmod_utils.so` | +| `utils/utils_lilv.cpp` | Plugin/pedalboard discovery via lilv; nested pedalboards added here | +| `utils/patchstorage.cpp` | C++ helpers for PatchStorage metadata | +| `html/js/patchstorage.js` | Browser-side PatchStorage store UI and API client | +| `docs/output-data-flow.md` | How `output_data_ready` / `data_finish` meter flow-control works | + +## Key features implemented in this fork + +### 1. PatchStorage integration + +The browser can open a **PatchStorage** tab to browse, install, update, and remove LV2 plugins and pedalboards from the cloud. + +- Default endpoint: `https://patchstorage.com/api/beta/patches` +- Default platform: **8046** ("LV2 Plugins") +- Default target: **8280** (`rpi-aarch64`) — the pi-stomp image is 64-bit ARM +- These values are configured through environment variables (`PATCHSTORAGE_*`) and exposed to the frontend as globals in `html/index.html`. +- Each installed patch writes a `patchstorage.json` into the bundle so the UI can detect "outdated" content and offer updates. + +> Historical note: the original hardcoded IDs `5027`/`5037` were deleted by PatchStorage. This fork already moved to the current IDs (`8046`/`8280`) via the systemd environment. + +### 2. Nested pedalboard directories + +Upstream mod-ui / lilv only loads pedalboards that are **immediate children** of each LV2_PATH entry. We added a bounded directory walk in `utils/utils_lilv.cpp` so boards can live one level deeper, e.g. + +``` +~/.pedalboards/ + Live/ + FuzzSong.pedalboard/ + Studio/ + CleanPlate.pedalboard/ +``` + +The walk is intentionally defensive: +- max 2 levels deep +- skips hidden dirs, `.git`, `node_modules`, `__pycache__` +- only appends a sub-folder to `LV2_PATH` if it directly contains at least one `*.pedalboard` child +- caps each directory scan at 1024 entries and warns once + +### 3. Output clipping indicator + +A WebSocket message `clip_status