From 0fb8ed303b20f49ffbb11fb112ecc537a4c060d7 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 4 Jul 2026 22:30:04 -0400 Subject: [PATCH 1/8] Pass through beat_sync messages from mod-host --- docs/output-data-flow.md | 41 ++++++++++++++++++++++++++++++++++++++++ mod/host.py | 7 +++++++ 2 files changed, 48 insertions(+) diff --git a/docs/output-data-flow.md b/docs/output-data-flow.md index 2e2d9d1b..857a04de 100644 --- a/docs/output-data-flow.md +++ b/docs/output-data-flow.md @@ -9,6 +9,47 @@ mod-host continuously produces two kinds of feedback on its read socket (port 55 These share the same socket and the same flow-control mechanism. +## Plugin Self-Writes to Input Ports Are Never Echoed + +Both `output_set` and `param_set` require mod-host itself to be the one that changed +the value. There is no mechanism that notices a *plugin* silently overwriting its own +port buffer inside `run()` and forwards that as feedback. + +Concretely, in `mod-host/src/effects.c`: + +- **`output_set`** is gated by `HINT_MONITORED` (`effects.c:229`, `// outputs only`). + `effects_monitor_output_parameter` (`effects.c:6563`) resolves the symbol via + `FindEffectOutputPortBySymbol` — it requires an `lv2:OutputPort`. A plugin cannot + get this feedback for an `lv2:InputPort`, full stop, regardless of what the plugin + does with that port's buffer at runtime. +- **`param_set`** is sent from `SetPortValue` (`effects.c:2311`, broadcast at + `effects.c:2355-2370`) — but only when *mod-host* is the one calling it: a browser + `param_set` command, an HMI actuator, or a MIDI-learned CC (`effects.c:2755-2763`, + which computes a value from the raw CC and mod-host's own min/max mapping *before* + the plugin ever runs). None of these paths re-check the port after `run()` returns. + +So a plugin pattern like "declare a control port as `lv2:InputPort` so the host will +also feed external triggers into it, but have the plugin overwrite the same buffer +every block with its own authoritative computed value" (used by, e.g., the loopjefe +LV2 plugin's 5-state `state` port, one physical port doing double duty as trigger-in +and status-out) gets **no feedback loop at all** for the plugin's own correction: + +1. A MIDI CC or `param_set` write lands in the port buffer and gets echoed to the + browser (`SetPortValue`) — this is the *raw incoming* value, not anything the + plugin has computed yet. +2. The plugin's `run()` reads that value, treats it as "changed, thus a trigger", + computes its own real next value internally, and overwrites the same buffer + directly with a raw pointer write. +3. Nothing observes step 2. mod-ui never learns the corrected value; the browser + widget is left displaying whatever raw value survived step 1, which can be + arbitrarily wrong relative to the plugin's actual internal state. + +The plugin's own internal logic/engine state is unaffected — this only desyncs +whatever a browser modgui renders from the port's displayed value. If a plugin needs +the GUI to reflect a runtime-computed correction like this, it needs a genuine +`lv2:OutputPort` mirror that `MONITOR_OUTPUT` can actually watch; reusing the +input-port buffer for both directions does not get an echo through either path above. + ## The `data_finish` / `output_data_ready` Handshake mod-host batches one frame of output data, then signals the end: diff --git a/mod/host.py b/mod/host.py index 86462ce8..f05e9e34 100644 --- a/mod/host.py +++ b/mod/host.py @@ -1834,6 +1834,13 @@ def process_read_message_body(self, msg): except Exception as e: logging.exception(e) + elif cmd == "beat_sync": + fields = data.split() + if len(fields) != 4: + logging.warning("[host] malformed beat_sync: %r", data) + return + self.msg_callback("beat_sync %s %s %s %s" % fields) + elif cmd == "log": ltype, lmsg = data.split(" ", 1) self.msg_callback("log " + data) From 895fcb743d02efdd0c972aa2b1ca407daaf67ccb Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 12:10:57 -0400 Subject: [PATCH 2/8] Beat sync --- mod/host.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mod/host.py b/mod/host.py index f05e9e34..a3396365 100644 --- a/mod/host.py +++ b/mod/host.py @@ -1835,11 +1835,13 @@ def process_read_message_body(self, msg): logging.exception(e) elif cmd == "beat_sync": + # t_us bpm bpb beat_in_bar — a clock sample (t_us=now), not a + # back-dated downbeat event; see mod-host src/effects.c. fields = data.split() if len(fields) != 4: logging.warning("[host] malformed beat_sync: %r", data) return - self.msg_callback("beat_sync %s %s %s %s" % fields) + self.msg_callback("beat_sync %s %s %s %s" % tuple(fields)) elif cmd == "log": ltype, lmsg = data.split(" ", 1) From 4f7d96a040ecfb7e844f84ce67ed8d9e2ceb5044 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 18 Aug 2026 22:54:07 -0400 Subject: [PATCH 3/8] Deliver output_set to local clients pi-stomp renders plugin state from output_set: loopjefe's looper state drives its footswitch LED, LCD slot, and loop-progress border. Suppressing it left that state permanently stale. The suppression existed to keep meter traffic off the local socket, but that fear was unfounded: a live gareus meter measures ~30 msg/s, not the 375/s per-cycle ceiling, because plugins update monitored outputs at about UI refresh rate and mod-host discards the rest. pi-stomp drops the ports it hasn't subscribed to anyway. data_ready stays suppressed. pi-stomp echoes it from the socket read loop before queueing, so acking would provide no real backpressure -- it is functionally identical to the existing any_non_local_received self-ack while putting pi-stomp's 10ms loop inside mod-host's feedback path. Co-Authored-By: Claude Opus 5 --- mod/session.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mod/session.py b/mod/session.py index 58a6f9dd..89602296 100644 --- a/mod/session.py +++ b/mod/session.py @@ -237,7 +237,7 @@ def web_cv_addressing_plugin_port_remove(self, uri, callback): # 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. + # Local clients are exempt from data_ready flow control (see msg_callback). if ws.request.remote_ip in ('127.0.0.1', '::1'): ws._is_local = True @@ -428,7 +428,15 @@ def msg_callback(self, msg): any_non_local_received = False for ws in self.websockets: if getattr(ws, '_is_local', False): - if is_output_set or is_data_ready: + # output_set does reach local clients: pi-stomp renders plugin + # state (loopjefe's looper state drives its footswitch LED and + # LCD slot) from it, and drops the ports it hasn't subscribed + # to. Measured cost of a live meter is ~30 msg/s, not the + # per-cycle rate -- plugins update monitored outputs at about + # UI refresh and mod-host discards the rest. + # data_ready stays suppressed: acking it would put pi-stomp's + # 10ms loop inside mod-host's feedback path. + if is_data_ready: continue elif is_output_set: if getattr(ws, '_is_background', False) or not getattr(ws, '_meter_ready', True): From 6a5c4ec240bed74136f53052c367b58e212ddce1 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 4 Jul 2026 22:30:04 -0400 Subject: [PATCH 4/8] Pass through beat_sync messages from mod-host --- docs/output-data-flow.md | 41 ++++++++++++++++++++++++++++++++++++++++ mod/host.py | 7 +++++++ 2 files changed, 48 insertions(+) diff --git a/docs/output-data-flow.md b/docs/output-data-flow.md index 2e2d9d1b..857a04de 100644 --- a/docs/output-data-flow.md +++ b/docs/output-data-flow.md @@ -9,6 +9,47 @@ mod-host continuously produces two kinds of feedback on its read socket (port 55 These share the same socket and the same flow-control mechanism. +## Plugin Self-Writes to Input Ports Are Never Echoed + +Both `output_set` and `param_set` require mod-host itself to be the one that changed +the value. There is no mechanism that notices a *plugin* silently overwriting its own +port buffer inside `run()` and forwards that as feedback. + +Concretely, in `mod-host/src/effects.c`: + +- **`output_set`** is gated by `HINT_MONITORED` (`effects.c:229`, `// outputs only`). + `effects_monitor_output_parameter` (`effects.c:6563`) resolves the symbol via + `FindEffectOutputPortBySymbol` — it requires an `lv2:OutputPort`. A plugin cannot + get this feedback for an `lv2:InputPort`, full stop, regardless of what the plugin + does with that port's buffer at runtime. +- **`param_set`** is sent from `SetPortValue` (`effects.c:2311`, broadcast at + `effects.c:2355-2370`) — but only when *mod-host* is the one calling it: a browser + `param_set` command, an HMI actuator, or a MIDI-learned CC (`effects.c:2755-2763`, + which computes a value from the raw CC and mod-host's own min/max mapping *before* + the plugin ever runs). None of these paths re-check the port after `run()` returns. + +So a plugin pattern like "declare a control port as `lv2:InputPort` so the host will +also feed external triggers into it, but have the plugin overwrite the same buffer +every block with its own authoritative computed value" (used by, e.g., the loopjefe +LV2 plugin's 5-state `state` port, one physical port doing double duty as trigger-in +and status-out) gets **no feedback loop at all** for the plugin's own correction: + +1. A MIDI CC or `param_set` write lands in the port buffer and gets echoed to the + browser (`SetPortValue`) — this is the *raw incoming* value, not anything the + plugin has computed yet. +2. The plugin's `run()` reads that value, treats it as "changed, thus a trigger", + computes its own real next value internally, and overwrites the same buffer + directly with a raw pointer write. +3. Nothing observes step 2. mod-ui never learns the corrected value; the browser + widget is left displaying whatever raw value survived step 1, which can be + arbitrarily wrong relative to the plugin's actual internal state. + +The plugin's own internal logic/engine state is unaffected — this only desyncs +whatever a browser modgui renders from the port's displayed value. If a plugin needs +the GUI to reflect a runtime-computed correction like this, it needs a genuine +`lv2:OutputPort` mirror that `MONITOR_OUTPUT` can actually watch; reusing the +input-port buffer for both directions does not get an echo through either path above. + ## The `data_finish` / `output_data_ready` Handshake mod-host batches one frame of output data, then signals the end: diff --git a/mod/host.py b/mod/host.py index 6f92bbb5..b84fdec5 100644 --- a/mod/host.py +++ b/mod/host.py @@ -1842,6 +1842,13 @@ def process_read_message_body(self, msg): except Exception as e: logging.exception(e) + elif cmd == "beat_sync": + fields = data.split() + if len(fields) != 4: + logging.warning("[host] malformed beat_sync: %r", data) + return + self.msg_callback("beat_sync %s %s %s %s" % fields) + elif cmd == "log": ltype, lmsg = data.split(" ", 1) self.msg_callback("log " + data) From ca4c708f5687ed4c719e992cc597c76c19346010 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sat, 11 Jul 2026 12:10:57 -0400 Subject: [PATCH 5/8] Beat sync --- mod/host.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/mod/host.py b/mod/host.py index b84fdec5..41484b7a 100644 --- a/mod/host.py +++ b/mod/host.py @@ -1843,11 +1843,13 @@ def process_read_message_body(self, msg): logging.exception(e) elif cmd == "beat_sync": + # t_us bpm bpb beat_in_bar — a clock sample (t_us=now), not a + # back-dated downbeat event; see mod-host src/effects.c. fields = data.split() if len(fields) != 4: logging.warning("[host] malformed beat_sync: %r", data) return - self.msg_callback("beat_sync %s %s %s %s" % fields) + self.msg_callback("beat_sync %s %s %s %s" % tuple(fields)) elif cmd == "log": ltype, lmsg = data.split(" ", 1) From de84c62f150aef23e65fff6eb14faf6c683a9116 Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Tue, 18 Aug 2026 22:54:07 -0400 Subject: [PATCH 6/8] Deliver output_set to local clients pi-stomp renders plugin state from output_set: loopjefe's looper state drives its footswitch LED, LCD slot, and loop-progress border. Suppressing it left that state permanently stale. The suppression existed to keep meter traffic off the local socket, but that fear was unfounded: a live gareus meter measures ~30 msg/s, not the 375/s per-cycle ceiling, because plugins update monitored outputs at about UI refresh rate and mod-host discards the rest. pi-stomp drops the ports it hasn't subscribed to anyway. data_ready stays suppressed. pi-stomp echoes it from the socket read loop before queueing, so acking would provide no real backpressure -- it is functionally identical to the existing any_non_local_received self-ack while putting pi-stomp's 10ms loop inside mod-host's feedback path. Co-Authored-By: Claude Opus 5 --- mod/session.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/mod/session.py b/mod/session.py index 0f39be4b..f9bf8864 100644 --- a/mod/session.py +++ b/mod/session.py @@ -239,7 +239,7 @@ def web_cv_addressing_plugin_port_remove(self, uri, callback): # 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. + # Local clients are exempt from data_ready flow control (see msg_callback). if ws.request.remote_ip in ('127.0.0.1', '::1'): ws._is_local = True @@ -419,7 +419,15 @@ def msg_callback(self, msg): any_non_local_received = False for ws in self.websockets: if getattr(ws, '_is_local', False): - if is_output_set or is_data_ready: + # output_set does reach local clients: pi-stomp renders plugin + # state (loopjefe's looper state drives its footswitch LED and + # LCD slot) from it, and drops the ports it hasn't subscribed + # to. Measured cost of a live meter is ~30 msg/s, not the + # per-cycle rate -- plugins update monitored outputs at about + # UI refresh and mod-host discards the rest. + # data_ready stays suppressed: acking it would put pi-stomp's + # 10ms loop inside mod-host's feedback path. + if is_data_ready: continue elif is_output_set: if getattr(ws, '_is_background', False) or not getattr(ws, '_meter_ready', True): From 16355113cc775b1c8066d40aee4dc09e5e22a5ae Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 23 Aug 2026 23:18:20 -0400 Subject: [PATCH 7/8] Fix understanding --- docs/output-data-flow.md | 106 ++++++++++++++++++++++----------------- 1 file changed, 60 insertions(+), 46 deletions(-) diff --git a/docs/output-data-flow.md b/docs/output-data-flow.md index 857a04de..c747bc60 100644 --- a/docs/output-data-flow.md +++ b/docs/output-data-flow.md @@ -9,46 +9,58 @@ mod-host continuously produces two kinds of feedback on its read socket (port 55 These share the same socket and the same flow-control mechanism. -## Plugin Self-Writes to Input Ports Are Never Echoed - -Both `output_set` and `param_set` require mod-host itself to be the one that changed -the value. There is no mechanism that notices a *plugin* silently overwriting its own -port buffer inside `run()` and forwards that as feedback. - -Concretely, in `mod-host/src/effects.c`: - -- **`output_set`** is gated by `HINT_MONITORED` (`effects.c:229`, `// outputs only`). - `effects_monitor_output_parameter` (`effects.c:6563`) resolves the symbol via - `FindEffectOutputPortBySymbol` — it requires an `lv2:OutputPort`. A plugin cannot - get this feedback for an `lv2:InputPort`, full stop, regardless of what the plugin - does with that port's buffer at runtime. -- **`param_set`** is sent from `SetPortValue` (`effects.c:2311`, broadcast at - `effects.c:2355-2370`) — but only when *mod-host* is the one calling it: a browser - `param_set` command, an HMI actuator, or a MIDI-learned CC (`effects.c:2755-2763`, - which computes a value from the raw CC and mod-host's own min/max mapping *before* - the plugin ever runs). None of these paths re-check the port after `run()` returns. - -So a plugin pattern like "declare a control port as `lv2:InputPort` so the host will -also feed external triggers into it, but have the plugin overwrite the same buffer -every block with its own authoritative computed value" (used by, e.g., the loopjefe -LV2 plugin's 5-state `state` port, one physical port doing double duty as trigger-in -and status-out) gets **no feedback loop at all** for the plugin's own correction: - -1. A MIDI CC or `param_set` write lands in the port buffer and gets echoed to the - browser (`SetPortValue`) — this is the *raw incoming* value, not anything the - plugin has computed yet. -2. The plugin's `run()` reads that value, treats it as "changed, thus a trigger", - computes its own real next value internally, and overwrites the same buffer - directly with a raw pointer write. -3. Nothing observes step 2. mod-ui never learns the corrected value; the browser - widget is left displaying whatever raw value survived step 1, which can be - arbitrarily wrong relative to the plugin's actual internal state. - -The plugin's own internal logic/engine state is unaffected — this only desyncs -whatever a browser modgui renders from the port's displayed value. If a plugin needs -the GUI to reflect a runtime-computed correction like this, it needs a genuine -`lv2:OutputPort` mirror that `MONITOR_OUTPUT` can actually watch; reusing the -input-port buffer for both directions does not get an echo through either path above. +## A Plugin Cannot Write to Its Own Input Port + +A plugin must not write to an input port. The host owns the buffer of an +input port. The host can write to that buffer before each block. The host can +also give one buffer to more than one plugin. Thus a value that a plugin +writes into an input port is not safe, and no host reads that value back. + +In LV2 a port is an `lv2:InputPort` or an `lv2:OutputPort`. A port cannot be +both. A control port that operates in two directions does not exist. + +mod-host obeys this rule. Neither of its two feedback messages looks at a port +after `run()` returns: + +- `output_set` is controlled by `HINT_MONITORED` (`effects.c:229`, + `// outputs only`). The function `effects_monitor_output_parameter` + (`effects.c:6563`) finds the symbol with `FindEffectOutputPortBySymbol`, + which accepts an `lv2:OutputPort` only. A plugin cannot get this feedback + for an `lv2:InputPort`. +- `param_set` comes from `SetPortValue` (`effects.c:2311`, sent at + `effects.c:2355-2370`). mod-host sends this message only when mod-host + itself sets the value: a `param_set` command from the browser, an HMI + actuator, or a MIDI-learned CC (`effects.c:2755-2763`). The CC path + calculates the value from the raw CC data and from the min/max limits of + mod-host. It does this before the plugin runs. + +Thus, if a plugin writes to an input port, mod-ui does not learn the new +value. The browser continues to show the last value that mod-host sent. That +value can be very different from the true state of the plugin. + +### The Correct Pattern: A Trigger Input and a Status Output + +To show a value that the plugin calculates, use two ports: + +1. An `lv2:InputPort` with the `pprops:trigger` property. The user or a MIDI + CC operates this port. The host sets the port back to its default value + after the block. +2. An `lv2:OutputPort` that holds the state that the plugin calculates. + Add the symbol of this port to `modgui:monitoredOutputs` in the modgui + data. `utils_lilv.cpp:2502` reads that list. Then `host.py:2582` and + `host.py:4025` send `monitor_output ` to mod-host when + the plugin starts and when a pedalboard loads. This sets `HINT_MONITORED`, + and mod-host starts to send `output_set` for that port. + +The loopjefe LV2 plugin used one `state` port for both directions. That port +gave no feedback. The plugin now has an `lv2:OutputPort` for `state` and +`measure_number`, and four trigger input ports: `advance`, `reset`, `undo`, +and `redo`. + +A `modgui:monitoredOutputs` value goes to the JavaScript file of the modgui as +a `change` event (`modgui.js:522`, `setOutputPortValue`). A control widget +cannot show an output port. Thus the JavaScript file of the plugin must write +to the markup of the icon. ## The `data_finish` / `output_data_ready` Handshake @@ -59,7 +71,7 @@ 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 --------->| + |-- param_set X :bypass 1 -->|-- output_set inst B --------->| |-- data_finish ------------>|-- param_set X :bypass 1.0 --->| | | | | |-- data_ready N -------------->| @@ -81,7 +93,7 @@ all other feedback) stall if `output_data_ready` is never sent. ```python if msg == "data_finish": - if self.web_connected: # any WebSocket client is open + 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) @@ -89,10 +101,12 @@ if msg == "data_finish": # msg_callback sets web_data_ready_ok = True if it self-acknowledged, # so the timer is only armed when a real ack is still outstanding. if not self.web_data_ready_ok and self.last_data_finish_handle is None: - self.last_data_finish_handle = ioloop.call_later(0.15, self.send_output_data_ready_later) + self.last_data_finish_handle = ioloop.call_later( + 0.15, self.send_output_data_ready_later + ) return else: - yield gen.Task(self.send_output_data_ready, now) # no client: use timer + yield gen.Task(self.send_output_data_ready, now) # no client: use timer ``` ### Client echo path (`session.py: ws_data_ready`) @@ -111,9 +125,9 @@ def ws_data_ready(self, counter, ws): ```python def send_output_data_ready(self, now, callback): - self.web_data_ready_ok = True # mark as done before sending + self.web_data_ready_ok = True # mark as done before sending ... - self.send_notmodified("output_data_ready", callback) # write socket → mod-host + self.send_notmodified("output_data_ready", callback) # write socket → mod-host ``` `web_data_ready_ok` is set here (rather than only in `ws_data_ready`) so that if the From 118e69216541407e944067d5196ae986aa9426ad Mon Sep 17 00:00:00 2001 From: Cam Gorrie Date: Sun, 23 Aug 2026 23:18:47 -0400 Subject: [PATCH 8/8] Pass flags for beat_sync echo --- mod/host.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mod/host.py b/mod/host.py index 41484b7a..34218251 100644 --- a/mod/host.py +++ b/mod/host.py @@ -1843,13 +1843,13 @@ def process_read_message_body(self, msg): logging.exception(e) elif cmd == "beat_sync": - # t_us bpm bpb beat_in_bar — a clock sample (t_us=now), not a - # back-dated downbeat event; see mod-host src/effects.c. + # t_us bpm bpb beat_in_bar flags — a clock sample (t_us=now). + # Refer to mod-host src/effects.c. fields = data.split() - if len(fields) != 4: + if len(fields) != 5: logging.warning("[host] malformed beat_sync: %r", data) return - self.msg_callback("beat_sync %s %s %s %s" % tuple(fields)) + self.msg_callback("beat_sync %s %s %s %s %s" % tuple(fields)) elif cmd == "log": ltype, lmsg = data.split(" ", 1)