From 2f8830e1de0d7fd5e2939da75063dab2244409f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 16:55:51 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=EC=8B=9C=EC=9E=91=20=E2=80=94=20Patternflo?= =?UTF-8?q?w=20=EC=A0=95=EB=A6=AC=204=EB=8B=A8=EA=B3=84=20PR=2023?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012EVATQKEEZsieGxy4i6vJQ From 4b86878e8c4feec41df14cfc98b72140e1d828fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:03:55 +0000 Subject: [PATCH 2/3] docs: settle the spec lines that said two things Where rest-api.md or midi-spec.md contradicted itself, the route's own section stands (step 4 question 27), and the vectors that quoted a changed line follow. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012EVATQKEEZsieGxy4i6vJQ --- docs/midi-spec.md | 13 +++++++++---- docs/rest-api-vectors/absent-caps.json | 2 +- docs/rest-api-vectors/state-params-subset.json | 3 ++- docs/rest-api.md | 17 +++++++++-------- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/docs/midi-spec.md b/docs/midi-spec.md index dde8499a..1a71dafe 100644 --- a/docs/midi-spec.md +++ b/docs/midi-spec.md @@ -29,15 +29,16 @@ The mapping below is transport-independent. What ships today: | **USB** (a USB-MIDI class device — the MIDI edition) | The DevKit's USB port, the one the panel is flashed over, plugged into the computer. No driver and no session: the panel enumerates as a MIDI device named `Patternflow` (a virtual serial port comes with it — that is the console's log and Improv, leave it be). MIDI edition only: the port has to be the S3's USB-OTG controller for a MIDI device to exist, and that is a build-time choice ([`EDITIONS.md`](EDITIONS.md)). **Data only** — the panel is powered from `J4` as always, never from that cable. Both transports share the map below and can be up at once. | Channel: **1** by default (`PF_MIDI_CHANNEL`; `0` listens on every channel -and sends on 1). MIDI Thru is off — the panel never echoes a host's messages -back to it. +and sends on 1). MIDI Thru is off: the panel never echoes a host's CC or note +back to it. A Program Change from the host comes back as the pattern change it +made, as every pattern change does. ## Host → device | Message | Meaning | |---|---| | **CC 20 · 21 · 22 · 23** | Knob 1–4, **absolute**. `0..127` maps to the device's `0..1000` bus (rounded; 127 = 1000). The value **holds** until a hand turns that knob, exactly like `POST /api/params` and MQTT `param/N`. | -| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (two's-complement offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | +| **CC 24 · 25 · 26 · 27** | Knob 1–4, **relative**. `64` = no motion, `65` = +1 detent, `63` = −1, and so on (binary offset). Merged with physical motion at 1× per step — the same lane as OSC's `/patternflow/knob/N/delta`. Does not release an absolute hold. | | **Note 60 · 61 · 62 · 63** (C4–D♯4) | Button 1–4. Note-on = press (edge), the button reads as held for as long as the note is held; note-off (or velocity 0) releases. | | **Program Change `n`** | Select pattern index `n` (0-based, the order on `/patterns`). Treated as a person's choice: it is remembered across reboots, like a knob pick. Ignored while a show claims the panel. | @@ -48,7 +49,7 @@ the channel is `0`. The device's rules, not MIDI's: **a hand on an encoder beats everything.** An absolute CC pins a knob until that encoder moves; relative CCs and notes are -just more input, and the audio lanes yield to both. +just more input, and the audio lanes yield to the hand and to an absolute CC. ## Device → host @@ -135,6 +136,10 @@ most. - **1.2** (unreleased) — additive receive diagnostics, transport service independent of display frames, and suppression of outbound CCs generated from absolute-bus compatibility clicks. No new transport or CC mapping. + Where this file said two things, the section that defines the message now + stands: CC 24–27 are binary offset, as their examples always were, a host's + Program Change comes back as the pattern change it made, and the audio lanes + yield to a hand and an absolute CC. - **1.1** (2026-09-06) — outbound CC 24–27 paced: one message per knob per 50 ms at most (`PF_MIDI_OUT_MIN_INTERVAL_MS`), the final value always sent; `rel` carries the steps summed since the previous message. On the Audio diff --git a/docs/rest-api-vectors/absent-caps.json b/docs/rest-api-vectors/absent-caps.json index a86bf556..3cc4f5d5 100644 --- a/docs/rest-api-vectors/absent-caps.json +++ b/docs/rest-api-vectors/absent-caps.json @@ -1,6 +1,6 @@ { "contract": "docs/rest-api.md#get-apistatus", - "says": "The default build reports `[\"patterns\",\"params\",\"sleep\"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc` and `audio`, Performance adds `weather`, `mqtt` and `shows`.", + "says": "The default build reports `[\"patterns\",\"params\",\"sleep\"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`.", "features": [], "steps": [{ "send": { "http": "GET /api/status" }, "expect": { "reply": { "status": 200, "body": { "caps": ["patterns", "params", "sleep"] } } } }] } diff --git a/docs/rest-api-vectors/state-params-subset.json b/docs/rest-api-vectors/state-params-subset.json index 6a066ca6..88c3abff 100644 --- a/docs/rest-api-vectors/state-params-subset.json +++ b/docs/rest-api-vectors/state-params-subset.json @@ -1,6 +1,6 @@ { "contract": "docs/rest-api.md#post-apiparams", - "says": "`p1`..`p4`, each `0..1000`, any subset; at least one required.", + "says": "`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` or `dN`.", "features": [], "steps": [ { @@ -11,6 +11,7 @@ } }, { "send": { "http": "POST /api/params" }, "expect": { "reply": { "body": { "ok": false, "error": "" } } } }, + { "send": { "http": "POST /api/params?d2=1" }, "expect": { "reply": { "status": 200, "body": { "ok": true } } } }, { "send": { "http": "POST /api/params?p2=1001" }, "expect": { "reply": { "body": { "ok": false, "error": "" } }, "status": { "paramActive": [true, false, true, false] } } diff --git a/docs/rest-api.md b/docs/rest-api.md index 9aafd145..9c2bc8f7 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -35,7 +35,7 @@ Three properties of this server are not obvious from the endpoint list, and each Two things stayed true. **Installing patterns still evicts the running module for the duration of the upload batch** — the panel shows PAUSED and restores itself — which is what `status.consolePaused` reports now; treat it as "an install is in progress", not "somebody looked at the console". And a page load still momentarily competes with the render loop on this one-connection server, so sending a person to `http://patternflow.local/` is fine and expected; **polling HTML pages remains a bug** — automated clients keep to `/api/*`. -**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` and `GET /api/patterns/select` therefore return the *previous* state. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) +**Some writes report the state before they take effect.** Stopping a DMA engine, reclocking the CPU, reading FATFS and running the ELF relocator are not things a handler does inside an open HTTP transaction — the firmware queues them and `loop()` performs them. `POST /api/sleep` therefore returns the *previous* state. `GET /api/patterns/select` answers with the pattern it queued, and `/api/patterns` and `/api/status` name the old one until the next frame. Set your own state optimistically and let the next poll confirm it; do not parse the reply's state field as the new value. (Since 3.9.1 the same rule holds from the other side: handlers run on the network core, and the few that must touch what the frame is using are executed on the loop task at the frame boundary — `loopSyncServed` in status counts them.) ## Device state @@ -107,7 +107,7 @@ The numbers that explain a device when something is off. Requires `PF_STATUS_HTT | `thumbs` | Since 1.5: boot counters `captures` (cache refreshed), `reads` (disk image adopted), `writes` (save completed). `captureMaxUs` is the maximum loop-side capture duration; `ioMaxUs` is the maximum background file-operation wall time, including scheduling/filesystem contention. Both are µs. The cache and its temporary I/O snapshot use PSRAM only. | | `load` | The last module load, in µs: `total`, `read` (flash), `relocate`, `setup` (the pattern's own). Since 1.4 also where its sections landed, in bytes: `internal` and `psram`. A big `internal` is the number to look at when the console dies the moment a pattern is chosen — the loader now sends data sections over 16 KB, or any that would leave under 24 KB of internal heap, to PSRAM (`PF_MODULE_DATA_INTERNAL_MAX`, `PF_MODULE_INTERNAL_RESERVE`). Since 1.5 `code` says where the module's code runs from, `"psram"` or `"internal"`: in PSRAM it is counted in `psram` and takes nothing from the internal heap, and `"internal"` on a module means PSRAM could not hold it or `moduleMemory.codePolicy` is `0` - which it becomes, until reboot, if code placed in PSRAM ever fails to read back on this unit. Since 1.5 data sections go to PSRAM first as well, whatever their size, so `internal` is `0` on a module that loaded normally. Since 1.5 `load` describes the module on the panel: `resumed` is `true` when it came back from residency rather than from a load - the timings are then its own, from when it first loaded, and nothing was read or set up this time - and `resumeUs` is what coming back took; `false` and `0` after a fresh load. With no module on the panel (a preset, or a load that failed) `internal` and `psram` are `0`, `code` reads `"internal"`, `resumed` is `false` and `resumeUs` `0`; the timings are left as the last module had them - except after a failed load, where `read`, and `relocate` if the load got that far, describe the attempt that failed. | | `variant` | Which firmware this is: `"core"`, or a variant's own name. What the site's variant list matches, and what stops the update banner offering a core build on top of someone's chosen firmware. | -| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc` and `audio`, Performance adds `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | +| `caps` | What this build can do. **Probe this rather than assuming a feature exists.** The default build reports `["patterns","params","sleep"]` and nothing else; each [edition](EDITIONS.md) adds its own — Audio adds `osc`, `audio`, `audio-in` and `midi`, Performance adds `clock`, `weather`, `mqtt` and `shows`. `patterns` and `params` are on every build. | | `mqttRole` | `"off"`, `"publisher"` or `"subscriber"`. Decides whether the device obeys knob and pattern topics — see [Knobs](#knobs-and-parameters). | | `featureNav` | `[path, label, one-line description]` per console page the loaded features serve, e.g. `[["/audio-in","Audio","The panel hears the room…"]]`. What the console header and home screen build their feature links from; empty on the default build. | @@ -162,7 +162,8 @@ Erases the core dump and drops the record; `crash` is absent from the next statu Write the absolute parameter bus — the four channels a pattern reads as set-points beside the physical encoders. -`p1`..`p4`, each `0..1000`, any subset; at least one required. A channel is +`p1`..`p4`, each `0..1000`, any subset. A request needs at least one `pN` +or `dN`. A channel is **held** once written and released the moment somebody turns that knob, so an automated source can pin a look and hands always win it back. Patterns see it only if they declare `ABSOLUTE_READY` and were built at module ABI 2. @@ -592,8 +593,8 @@ A client that has to key on something stable should use the hostname and require | | HTTP | OSC | MIDI | MQTT | |---|---|---|---|---| | Read device state | **yes**, everything | announce burst + heartbeat | pattern changes only (Program Change) | knob / pattern / sleep topics, in Publisher role | -| Read knob positions | **yes**, `/api/mqtt`, any role | on change | on change, relative (CC 24–27) | in Publisher role | -| Write knobs | **no** | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | +| Read knob positions | **yes**, `/api/status` | on change | on change, relative (CC 24–27) | in Publisher role | +| Write knobs | **yes**, absolute (`POST /api/params`) and deltas (`d1`..`d4`) | **yes**, deltas | **yes**, absolute (CC 20–23) and deltas (CC 24–27) | **yes**, Subscriber role only | | Switch pattern | **yes** | yes | yes, Program Change | Subscriber role only | | Sleep / wake | **yes** | no | no | **yes**, either role | | Install / delete patterns | **yes** | no | no | no | @@ -601,13 +602,13 @@ A client that has to key on something stable should use the hostname and require | Latency | poll-bound, seconds | sub-frame | sub-frame | broker-bound | | Needs infrastructure | nothing | nothing | an RTP-MIDI driver on the host (built into macOS/iOS; rtpMIDI on Windows) | a broker | -In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and the only one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client wants HTTP for everything except knob writes, and MQTT for those. +In short: HTTP is the management and state transport, OSC and MIDI are the low-latency performance transports (MIDI is the one a DAW already speaks, and of the two the one with absolute knob values — [`midi-spec.md`](midi-spec.md)), MQTT is the one that reaches into home automation and mirrors one panel onto another. A home-automation client can do all of it over HTTP, knob writes included. ## Conventions for future additions - JSON lives under `/api/`; anything else on this server is a page for a person. - Parameters are query-string or form-encoded. There is no JSON request body anywhere, and adding one would need a body parser the firmware does not have. -- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. Success bodies vary; the ones that report a mutation start with `"ok":true`. +- Errors are `{"ok":false,"error":""}` with a `4xx`/`5xx` status. A path no route serves is the one exception: its `404` is plain text. Success bodies vary; the ones that report a mutation start with `"ok":true`. - A route whose handler has to run on the loop task answers `503 {"ok":false,"error":"render loop is not answering"}` when the loop has stopped coming round (`loopStalled` in status) — the pattern list, select, install, delete, format, the knob settings, and a feature's routes that reconfigure what its loop hook is ticking. Nothing was changed. Retrying helps only once `loopStalled` reads `false` again, which a pattern stuck in `draw()` never lets it: that panel has to be restarted (`POST /api/wifi/reboot` still answers). A request made in the first twenty seconds after the loop stops waits out the rest of them for that reply; later ones get it at once. A new handler that uses `PFLoopSync::run()` answers a `false` the same way, with `PatternflowHttp::sendLoopStalled()`. - A handler must never touch FATFS, the ELF loader, the DMA engine or the CPU clock. Queue the work and let `loop()` do it; the reply then reports the pre-transition state, which callers already expect. - A feature's endpoints exist only when that feature is composed in — the route itself is absent otherwise, so clients probe `caps` rather than expecting a soft 404. Core endpoints are gated by a core `PF_*_ENABLED` flag and must leave the sketch compiling at `0`. @@ -615,7 +616,7 @@ In short: HTTP is the management and state transport, OSC and MIDI are the low-l ## Version history -- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments. +- **1.5** (unreleased) — the hotspot (`hotspot` in status, `GET`/`POST /api/hotspot`); status gains `network` and `thumbs` diagnostics, `fsError` (why storage is not mounted) and `flashId`; a failed `POST /api/patterns/format` returns the reason as its `error`; `POST /api/wifi/reconnect` reconnects without a reboot; core name registration retries partial failures and preserves feature-owned services; the MIDI edition adds a `midiUsb` block to status ([`midi-spec.md`](midi-spec.md)); `GET`/`POST /api/knobs` and the `/knobs` page (encoder direction and edges per click, per knob, persisted); `PUT /update` takes a raw image, and an upload survives a stall of up to two minutes; `GET /api/wifi` gains `join` (what became of the last network asked for) and `GET /api/hotspot` gains `seen` (the names in range); a network added on the hotspot with no station link is tried at once; console pages are cached by build (`?v=` immutable, bare URLs `no-cache` with an `ETag` and `304`, another build's `v` redirected with `302`), the chrome by its CRC (`?h=`), only for a `Host` that can only be the panel; `GET /favicon.ico` answers `204`; status gains `build`, `viaHotspot` and `busy`; `load` gains `code` (where the module's code runs from); pattern names in `GET /api/patterns` and `/api/patterns/select` are JSON-escaped, so a quote or backslash in a title no longer makes the reply unparseable; a multipart request that stops mid-form is dropped - the connection is closed with no reply - instead of spinning the server into its watchdog, and leaves none of its fields behind for later requests; `moduleMemory` gains `codePolicy`; status gains `loopStackMin`; the hotspot password is JSON-escaped in `/api/hotspot`; a `POST` that is not multipart to `/api/patterns` or `/update` is answered instead of crashing the panel, a multipart boundary longer than 70 characters is refused, and a raw `PUT` sees its own query string; status gains `crash` (the core dump's summary, and the pattern and phase the reset interrupted) and `DELETE /api/crash` clears it; status gains `loopAgeMs`, `loopStalled` and `loopSyncGaveUp`, and a route that needs the render loop answers `503` (`render loop is not answering`) when a pattern never returns from `draw()`, where it used to take the whole server down with it; a module that has run stays loaded when another pattern takes over and resumes when picked again - `load` gains `resumed` and `resumeUs`, `moduleMemory` gains `resident`, and a module's data sections go to PSRAM first; `POST /api/audio-in` applies a band when any of its suffixed fields is present (it took one of five before, and answered `ok` to the rest without changing anything), and refuses a bad `band` before applying anything, with `ok:false` in the body; `POST /api/audio-in/reset` takes `band=N` and names the band in its answer, and a full reset now includes `attack`; `hzRange` is printed to two decimals; a microphone that failed to start or stopped answering (`source` beginning `synth`) no longer drives the knobs from the test signal; `/api/audio-in` ignores a value that is not a finite number (`nan`, `inf`) where it used to store it and print `nan` back, which no JSON parser reads, and a band whose low edge is in the top analysis bin reads 0 instead of putting `nan` into the poll and into `audioIn` in status; the microphone section documents what was already there - `smoothing`, `attack`, `meta`, `lutSet`, `lut`, the suffixed field names, `env`, `ext`, the `frame` post, `synth (mic stalled)`, that `spec` has 64 buckets, not 40, and that a body sent without the form `Content-Type` is not read as arguments; where this file said two things, the route's own section now stands: select answers with the pattern it queued, HTTP writes knobs and reads them from `/api/status`, `d1`..`d4` alone are a whole `POST /api/params`, `caps` names what each edition adds, and only a path no route serves gets a plain-text `404`. - **1.4** (2026-09-06) — `GET /api/patterns/file` gains `ext=thumb`; `GET /api/display` takes `brightness` and status reports it; status gains `resetReason` and `load.internal`/`load.psram`; console pages are served gzip-compressed (`Content-Encoding: gzip`); the page sender no longer truncates on a slow link; the server no longer trips the Core-0 watchdog on a request that stalls mid-header. - **1.3** (2026-09-04) — `GET`/`POST /api/clock` (Utility edition) and the `clock` block in status; `caps` gains `"clock"`. - **1.2** (2026-09-03) — the server is serviced on Core 0 (the one-connection rule stands; the render-pays rule is history); status gains `httpCore`, `netStackMin`, `loopSyncServed`/`loopSyncMaxUs`; `POST /api/params` documents `d1`..`d4` and how a held value reaches a legacy pattern; `GET /api/patterns/select` gains `step`; `GET`/`POST /api/audio` (Audio-React) are documented; `featureNav`'s microphone label is *Audio*. From 827b2539b08941d159aae91659306a992bf33a10 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 17:06:33 +0000 Subject: [PATCH 3/3] docs: separate the MIDI history clauses with semicolons Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012EVATQKEEZsieGxy4i6vJQ --- docs/midi-spec.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/midi-spec.md b/docs/midi-spec.md index 1a71dafe..f31ca293 100644 --- a/docs/midi-spec.md +++ b/docs/midi-spec.md @@ -137,8 +137,8 @@ most. independent of display frames, and suppression of outbound CCs generated from absolute-bus compatibility clicks. No new transport or CC mapping. Where this file said two things, the section that defines the message now - stands: CC 24–27 are binary offset, as their examples always were, a host's - Program Change comes back as the pattern change it made, and the audio lanes + stands: CC 24–27 are binary offset, as their examples always were; a host's + Program Change comes back as the pattern change it made; the audio lanes yield to a hand and an absolute CC. - **1.1** (2026-09-06) — outbound CC 24–27 paced: one message per knob per 50 ms at most (`PF_MIDI_OUT_MIN_INTERVAL_MS`), the final value always sent;