diff --git a/.github/workflows/console-sync.yml b/.github/workflows/console-sync.yml
index 68da1dcc5..c71f48789 100644
--- a/.github/workflows/console-sync.yml
+++ b/.github/workflows/console-sync.yml
@@ -49,8 +49,11 @@ jobs:
python-version: "3.x"
- name: Headers match console/*.html
run: python firmware/toolchain/console_pages.py check
- # audio-in.html is itself generated, from the browser extension's mapping
- # editor. The step above proves the header matches the page; this proves
- # the page matches the editor it was assembled from.
- - name: audio-in.html matches the extension's editor
+ # audio-in.html is itself generated: from the browser extension's mapping
+ # editor and the console/_audio_in* files. The step above proves the
+ # header matches the page; this proves the page is what those sources
+ # assemble to, and that it is inside its size budget (BUDGET in the
+ # script: the stamped page, gzip -9, as the panel sends it). The step
+ # above reports both too; this one keeps them a line of their own.
+ - name: audio-in.html matches its sources and is inside its size budget
run: python firmware/toolchain/build_audio_in_page.py --check
diff --git a/AUDIO_GUIDE.md b/AUDIO_GUIDE.md
index f0448d779..7893e9270 100644
--- a/AUDIO_GUIDE.md
+++ b/AUDIO_GUIDE.md
@@ -41,24 +41,42 @@ locally, and streams four knob lanes to the panel over your LAN.
**Install:** it's not on the Web Store — load it straight from this repo.
`chrome://extensions` → enable *Developer mode* → *Load unpacked* → pick
[`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/).
-(Details and screenshots in its [README](tools/patternflow-audio-extension/README.md).)
-
-**Use:** open a tab with sound → click the extension → **Start** (it captures
-the active tab) → enter your panel's address (`patternflow.local` or its IP)
-→ the knobs move with the music. (Make sure **Audio-React (AUD)** is turned on
-at the top of the console's **Audio** page or via the panel's NETWORK screen.)
+After pulling a newer copy, press *Reload* on its card there. Reload keeps the
+mapping you already had, so the starting point described below is a new
+install's. (Every control is described in its
+[README](tools/patternflow-audio-extension/README.md), and so is how to get
+that starting point on an older mapping.)
+
+**Use:** open a tab with sound → click the extension → enter your panel's
+address (`patternflow.local` or its IP) → **Start** (it captures the active
+tab). The corner of the popup says **Live** once the panel is being driven,
+and the knobs move with the music. If it says **Aud off**, the panel's
+Audio-React switch is off and the panel is ignoring what arrives: press
+**Turn on** in the popup. (The same switch is the `AUD` row of the panel's
+NETWORK screen and the *Browser extension and phone app* row on the console's
+**Audio** page.) If it says **Error**, the line under the address says what
+is wrong: no panel at that address, or a panel that does not run the Audio
+edition.
+
+While the tab is silent or paused the extension holds the knobs, each where
+its band rests, until you press **Stop**. A knob you turn by hand during a
+pause goes back there about five seconds after you let go.
**Then open the editor** (*Mapping editor ↗* in the popup) — this is where it gets
-good. Each of the four knobs is a **box drawn on the live spectrum**: the
-box's width is the frequencies it listens to, its height the loudness window
-it maps. Drag a box over the bass and knob 1 becomes a bass knob. Inside
-each box a **response curve** (presets or a hand-dragged bezier) shapes how
-it moves, an **output range** decides how far, and **attack/damping** set
-the ballistics — fast rise, slow fall. Boxes start in **auto**, breathing
-with the music; grab an edge to take manual control. A **preview** popup
-shows how a test signal would ride your settings before you commit.
-
-Boxes map to knobs 1:1 and that's fixed on purpose — box 2 *is* knob 2.
+good. Each of the four knobs, **K1** to **K4**, is a **box drawn on the live
+spectrum**: the box's width is the frequencies it listens to, its height the
+loudness window it maps. Drag K1's box over the bass and knob 1 becomes a
+bass knob. Pick a knob with the tabs under the plot, and its card has a
+**response curve** (presets, or a bezier you drag by its two handles) that
+shapes how it moves and an **output range** that decides how far; **Attack**
+and **Damping**, above the plot, set the ballistics for all four — fast rise,
+slow fall. A new install starts in **Auto range** with the Smooth curve: the
+boxes follow the loudness of the music. Drag a box's top or bottom edge to
+take the windows over by hand; switching Auto range off goes back to the
+windows you set. **Preview** puts a scope where the plot is and shows how a
+test signal would ride the selected knob's settings while you change them.
+
+Boxes map to knobs 1:1 and that's fixed on purpose — K2 *is* knob 2.
## The on-board microphone
@@ -125,22 +143,37 @@ Stick the mic wherever sound reaches it. Done.
### Turn it on
-Console → **Audio** page (`/audio-in`) → flip **Microphone** on. (**Audio-React (AUD)** beside it is the other input, the extension and the phone app.) That's the
-whole switch: on means listening and driving the knobs, off releases the
-hardware completely. The **gain** slider (1–16, default 8) is there if your
-room runs quiet — PDM mics on this chip are famously low-amplitude, and gain
-is the official answer.
-
-The same box editor from the extension lives on this page, dark-themed —
-same boxes, same curves. Mic settings are stored **on the panel** and
-survive reboots and firmware updates; *Reset all* puts everything back.
+Console → **Audio** page (`/audio-in`) → under **Sources**, flip
+**Microphone** on. That's the whole switch: on means listening and driving
+the knobs, off releases the hardware completely. The chip beside it reads
+`listening` once samples are arriving, and the line at the top of the page
+says who has the knobs. The **Input gain** slider (1–16, default 8) is there
+if your room runs quiet — PDM mics on this chip are famously low-amplitude,
+and gain is the official answer. (The row under it, *Browser extension and
+phone app*, is the other input: the same switch as `AUD` on the panel's
+NETWORK screen.)
+
+The same box editor from the extension lives on this page, under
+**Microphone mapping**, in the console's own look (light, or dark from the
+toggle in the header) — same boxes K1 to K4, same curves, same Preview. Its
+level axis is the microphone's, about −45 to +2 dB, and its frequencies stop
+at 8 kHz. Changes save to the panel as you make them; mic settings are stored
+**on the panel** and survive reboots and firmware updates. *Reset K1* puts the
+selected knob back to the firmware's default. *Reset mapping* asks to be
+pressed twice and then resets all four knobs, their curves, attack, damping
+and input gain; it leaves the Microphone switch and Auto range as they are.
### If something's off
- **Silence, but everything looks healthy** → SEL isn't grounded. This is
- the classic one.
-- **Mic not detected** → the firmware notices an absent/miswired mic and
- falls back to a synthetic test source, and says so on `/api/status`.
+ the classic one, and nothing can detect it: a mic with SEL tied high reads
+ as a quiet room.
+- **The chip says `not detected`** → the firmware sees nothing on the data
+ pin: no microphone, or no DAT lead (DAT goes to RX). Nothing is driven.
+- **The chip says `not answering`** → the microphone was there and has
+ stopped returning samples, or its driver did not start. The knobs are left
+ alone in this state too. `source` in `GET /api/audio-in` and in
+ `/api/status` has the firmware's own word for each of these.
- **The top band barely moves at normal volume** → physics, not a fault:
at room levels the highest octaves carry almost no energy. The auto range
is tuned around this.
@@ -203,9 +236,17 @@ microphone on any lane it's driving. The mic takes whatever's left. So you
can leave the mic on and still grab a knob whenever you want — it comes back
to the music a few seconds after you let go.
+The extension keeps driving its knobs through a silent or paused tab, so with
+both on, the microphone does not take over during a pause. Press **Stop** in
+the extension to give the knobs to the room, or **Mute** a knob in the
+extension's editor to give the microphone just that one. The phone app does
+not hold yet: half a second into a pause its knobs go back to the encoders,
+or to the microphone if that is on.
+
One thing to know: the **extension keeps its mapping in the browser**, while
the **mic and the phone app share the config stored on the panel**. Same
-editor everywhere, two homes for the settings.
+editor everywhere, two homes for the settings. The top of the console's Audio
+page says which source has the knobs right now.
---
@@ -223,8 +264,8 @@ You were pointed here to work on Patternflow's audio path. The map:
| `feature_audio_in.h` | Descriptor, the sampling/analysis task, where levels enter the knob pipeline. |
| `core_audio_pdm.h` | PDM mic driver (GPIO 43/44). Its header comment is the authoritative wiring + driver-choice record. |
| `core_audio_fft.h` | FFT and spectrum buckets. |
-| `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, NVS persistence. |
-| `core_audio_in_http.h` | `/audio-in` (editor page), `GET/POST /api/audio-in` (whole config), `POST /api/audio-in/reset`, the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. |
+| `core_audio_in_map.h` | Bands, curve LUTs (33-point, interpolate-only), attack/damping glide, auto-range, the defaults a reset restores, NVS persistence. Manual mode maps linearly in amplitude between a band's two edges; the page's dB axis is a conversion at the page's edge. |
+| `core_audio_in_http.h` | `/audio-in` (editor page), `GET /api/audio-in` (the configuration, read once), `POST /api/audio-in` (any subset: a request changes only the fields it carries), `POST /api/audio-in/reset` (everything, or one band with `band=N`), the `?levels=1` live poll, and the frame endpoint the phone posts spectra to. The contract is [`docs/rest-api.md`](docs/rest-api.md#microphone-audio-edition); the phone app is a second client of it, so fields are added, never renamed. |
| `audio_in_index.h` | **Generated** — never edit (chain below). |
**Firmware — MIDI** ([`firmware/patternflow/features/midi/`](firmware/patternflow/features/midi/)):
@@ -237,37 +278,83 @@ AppleMIDI library. Contract: [`docs/midi-spec.md`](docs/midi-spec.md).
([`firmware/patternflow/features/audio/`](firmware/patternflow/features/audio/)):
`core_audio_ws.h` is a WebSocket server on port 81. The wire contract is
[`docs/audio-ws-spec.md`](docs/audio-ws-spec.md) — clients build against the
-spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`.
+spec, not the source. Probe `GET /api/status` for `"audio"` in `caps`. A lane
+is released 500 ms after the last message that set it, so a sender that means
+to hold a value resends it; the spec says which senders in this tree do.
+`feature_audio.h` has the Audio-React switch and its two routes
+(`GET`/`POST /api/audio`).
-**The editor is authored once and baked down.** Edit at the top, regenerate
-downward; CI (`console-sync.yml`) fails on drift:
+**The editor is authored once and assembled for the panel.** The panel's
+`/audio-in` page is the extension's editor inside the panel's own frame, and
+one command builds it; CI (`console-sync.yml`) fails when the generated files
+are not what the sources assemble to, or the page is over its size budget:
```
-tools/patternflow-audio-extension/editor.{js,html,css} ← edit here
- → python firmware/toolchain/build_audio_in_page.py (dark theme + device adapter)
- → firmware/patternflow/console/audio-in.html
- → python firmware/toolchain/console_pages.py build
- → firmware/patternflow/features/audio_in/audio_in_index.h
+tools/patternflow-audio-extension/
+ editor.html the editor's body (between its EDITOR BODY lines)
+ editor.css, mapping.js down to their EXTENSION ONLY line
+ editor.js all of it
+firmware/patternflow/console/
+ _audio_in_bar.html the page around the editor: heading, Sources, footer
+ _audio_in.css the console's look
+ _audio_in_adapter.js window.PFAdapter over /api/audio-in
+ ↓ python firmware/toolchain/console_pages.py build
+firmware/patternflow/console/audio-in.html generated
+firmware/patternflow/features/audio_in/audio_in_index.h generated
```
+Preview without building: `python firmware/toolchain/console_serve.py`
+assembles `/audio-in` from those sources on every request and answers its API
+the way the firmware does; `/mock?audio=music`, `silence`, `off`, `fresh`,
+`nomic`, `stalled`, `phone`, `ext` and `noread` pick what the panel hears
+([`console/README.md`](firmware/patternflow/console/README.md#the-page-that-is-assembled-audio-in)).
+The page has a budget, `BUDGET` in `build_audio_in_page.py`: 17,500 bytes
+gzipped as the panel sends it. It is the heaviest page in the console and
+every byte crosses the panel's Wi-Fi, so whole-line comments and indentation
+are dropped as it is assembled and what only the extension needs stays below
+the `EXTENSION ONLY` lines.
+
+The seam between the editor and whatever feeds it is `window.PFAdapter`. The
+contract is the comment at the top of `editor.js`: the source owns its
+limits, its level axis, its defaults and where a level sits in a window, and
+the editor draws what it is told. The page's adapter follows the console's
+rules for a one-connection server (`console/README.md`, "Writing a page"):
+every request rides `PF.poll`/`PF.status`, nothing is saved until the
+panel's mapping has been read, and a save sends only the fields that changed.
+
**Chrome extension**
([`tools/patternflow-audio-extension/`](tools/patternflow-audio-extension/)):
-`editor.js` is the single shared editor module (adapter-isolated —
-`editor-adapter.js` binds chrome storage/frames, and includes a synthetic
-demo mode when opened outside the extension). `offscreen.js` does FFT →
-band mapping → glide → WS send. `popup.*` is the capture console;
-`background.js` owns capture lifecycle. Extension config lives in
-`chrome.storage`, not on the panel.
+`mapping.js` is the mapping model, one object (`PFMap`): defaults, the
+curves, level → knob. `editor.js` is the single shared editor module, and
+`editor-adapter.js` is its extension adapter (chrome storage, the capture's
+frames), the editor's source bar, and a synthetic demo source when the page
+is opened outside the extension. `offscreen.js` holds the capture: FFT →
+`PFMap` → glide → WS send, the resend that holds a steady value, and the
+state every page prints (`phase`). `popup.*` is the capture console and owns
+the panel's address; `background.js` relays between the pages and the
+offscreen document. The mapping lives in `chrome.storage`
+(`patternflowAudioConfig`; the address under `patternflowAudioHost`), not on
+the panel. The extension keeps its cream skin; only the panel's page wears
+the console's.
**Android app**
([`tools/patternflow-audio-android/`](tools/patternflow-audio-android/)):
-`Analyzer.kt` mirrors `offscreen.js`'s math; `DeviceLink.kt` speaks the WS
+`Analyzer.kt` mirrors the extension's analysis and mapping (`offscreen.js`
+and `mapping.js`); `DeviceLink.kt` speaks the WS
contract + syncs panel config over `/api/audio-in` + posts monitor frames;
`CaptureService.kt` is the playback-capture foreground service;
`EditorActivity.kt` is a WebView onto the panel's `/audio-in`. Build recipe
-in its README.
+in its README. Three things it carries by hand and that a change elsewhere
+can leave behind: it sends lanes only when they change, so it does not hold a
+steady value (the spec has the fix); it has its own copy of the three bezier
+presets, by the names the page stores (`p:smooth`, `p:sharp`, `p:fall`); and
+its WebView is set up without the part that shows a `window.confirm` dialog,
+which is why the page's Reset mapping is a button pressed twice. Open the
+app's editor once before releasing a change to `/audio-in`.
Rules of the road: firmware changes follow [FEATURE_GUIDE.md](FEATURE_GUIDE.md)
(hooks, checkers, `build.sh all`); protocol changes update the spec in the
-same PR; editor changes regenerate the chain above and keep the extension
-and console page identical — they are the same file, which is the point.
+same PR; API changes extend [`docs/rest-api.md`](docs/rest-api.md) and never
+rename what is there; editor changes run `console_pages.py build` so the
+panel's page follows — the editor's body is one file for both surfaces, which
+is the point.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f32312c4..b6c59addb 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,12 @@ All notable changes to Patternflow will be documented in this file, newest first
- **Parked patterns give their memory back.** Least recently used first, whenever a new load would otherwise start with less than 4.5 MB of PSRAM free (the most one pattern may allocate, plus 512 KB), and whenever a running pattern's own allocation would leave less than 512 KB. A pattern installed, replaced or deleted - from the console, or by a feature that installs patterns - is never resumed from the old file. Checked on two boards with 12-13 modules parked: internal heap stayed flat, an install emptied the table and PSRAM came back by exactly the parked bytes (683 KB), and with five 1.5 MB test modules PSRAM never fell under about 3.1 MB free, with no refused load. `/api/status` gains `load.resumed`, `load.resumeUs` and `moduleMemory.resident`.
- **What this takes away:** a pattern that wants a fresh start every time it is picked cannot have one. `setup()` runs once per load, as `firmware/CUSTOM_PATTERNS.md` now says.
- **The console is light, and its six core pages look like one thing.** It opens light; dark is still the toggle in the header, and a choice made there is remembered. Console, Patterns, Status, Wi-Fi, Knobs and Update now share one set of parts - buttons, chips, list rows, fields, notices, section titles - from a base block every page opens with (`console/README.md`, "The look"), with one accent, the LED orange, kept for what is live: the current tab, the playing pattern, the panel being on. The home page leads with the panel itself: what is playing with its arrows, on/sleep, brightness and the four knobs, with the device's facts under them in the same card; the arrow keys step through patterns as the arrow buttons do. Which edition the panel runs has a card of its own instead of being one more row among the facts: its name, its versions, the feature pages it carries as links, the update notice when there is one, and a button to the other editions (or a line saying the panel's own hotspot has no internet). The numbered index of every page with a paragraph each is now a list of one-line links. The header no longer scrolls sideways on a phone: Console and Patterns, the build's own feature pages, then Wi-Fi, Knobs, Update and Status, wrapped onto rows. Long explanations are a line, with the rest behind a disclosure where it is needed. Windows showed every small label in Courier New because no font in the monospace list was a Windows one; the list now includes Cascadia Mono and Consolas. The Knobs page has an *All* row that sets direction and edges per click for the four at once (it reads *Mixed* while they differ). The Status page shows the crash record (`crash` in `/api/status`) when there is one, with a button to clear it. Feature pages are not edited and take the light palette from the header. Home is 8.9 KB gzipped (was 10.9), Knobs grew by 0.15 KB for its All row, the others are the same or slightly smaller, and the header script is 9.6 KB. On a panel on the home network a page still opens in about 0.1 s the first time and in under 0.01 s after. Every request, control and message the pages had is still there; checked on a panel by stepping through patterns, sleeping and waking it, dimming it, dragging a knob and playing a pattern from the list. The guide's live console demo is regenerated to match.
+- **The Audio page is a console page now: it says who has the knobs, and it saves only what you changed** (Audio edition). `/audio-in` was the one page the light console left behind: its own dark palette, 12 px type, square corners, a second brand line. It opens light like the rest now, with the console's buttons, chips and list rows, and it does not scroll sideways on a phone (checked at 390 and 320 px). Above the editor is a **Sources** list. *Microphone* has a chip that reads `off`, `starting`, `listening`, `not detected` (nothing on the data pin; the page says to check the DAT lead) or `not answering`, with the input gain beside it; a board with no microphone fitted used to look the same as a healthy one in a silent room. *Browser extension and phone app* is the Audio-React switch, the same one as `AUD` on the NETWORK screen, with `off`, `waiting`, `connected` or `phone app`, shown only on a build that has that path. The line at the top of the page says which of them has the knobs. Under it the mapping editor is the extension's, with what that gained in this release (see Tools): the knobs are K1 to K4 with their tabs under the plot, the level axis is in dB, and Preview takes the plot's place. On this page the line in each box, the cursor on the curve and the readout now show where the firmware has the level (`outputs` and `levelsN` from the poll); the page used to work that out a second time on its own dB scale, which is not the scale the firmware maps a hand-set window on. A panel that has heard nothing draws its stored windows dashed, where it drew four boxes flat on the axis. *Reset K1* asks the firmware for that knob's default: the page's own table of defaults was the extension's, and put a 5-16 kHz band on an axis that ends at 8. *Reset mapping* is pressed twice and says what it resets, in place of a `confirm()` dialog, which the phone app's WebView is not set up to show. Saving: nothing is written until the panel's mapping has been read. A read that failed used to leave the editor on the extension's defaults with nothing on screen to say so, and the next touch saved four default bands over the panel's own. An edit now sends the fields that changed, one request at a time - an Attack nudge is `attack=0.611`, a box's top edge is that band's `inMin` and `inMax` - where every edit posted the whole mapping, 41 arguments, and two open tabs wrote their stale copies over each other's edits. A save the panel did not take is said so, tried once more, and kept for the next change; an edit made just before leaving the page is still sent. Polling: the levels poll rides the console's shared lane (`PF.poll`) as every other page's requests do, so it waits behind an upload in another tab and slows itself on a slow link. Measured on the mock, about 9 polls a second with the microphone on, one every 1.5 s with it off (it had been 9.2 a second there too, for numbers that cannot change), one a second while the panel is not answering, and 0.6 a second on a link as slow as the hotspot. Weight: 17.4 KB gzipped as the panel sends it, where it was 19.9, because comments and indentation are dropped as the page is assembled; CI fails the page above 17.5 KB. `console_pages.py build` assembles the page as its first step, so there is one build command where there were two, and `console_serve.py` serves it assembled from its sources on every request against a port of the firmware's audio handlers, with `/mock?audio=` states for music, a quiet room, no microphone, a stalled one, the phone app and a read that never answers. Checked on that mock in Chromium, light and dark, with no console errors in any state, and on a panel with the microphone: the mapping stored on it survived the update, the page read before it wrote, one slider step posted one field (`attack=0.645`) and held across a reload, and frame time and microphone drops were unchanged with the page open. Not yet on a phone or in the phone app's WebView.
+- **A microphone that is not delivering no longer moves the knobs, and the microphone API takes one field at a time** (Audio edition). When the PDM driver did not start, or the microphone stopped returning samples, the analysis carried on over its built-in three-tone test signal and those tones reached the knobs, while `source` said `synth (mic stalled)`. The microphone now drives the knobs only while it is returning samples. `POST /api/audio-in` applies a band when any one of its suffixed fields is present; before, a request carrying only `inMax2` was answered `{"ok":true}` and changed nothing, because only `hzMin`, `lut`, `knob`, `muted` or `outMin` counted as naming a band. A `band` outside 0..3 is refused with `400` before anything else in the request is applied. `POST /api/audio-in/reset` takes `band=N` and resets that band and its curve alone, naming it in the answer; a full reset now includes `attack`, and still leaves the microphone switch and auto range alone. `hzRange` is printed to two decimals (`31.25`, the bound a band is really held to). The poll reply is built in one allocation where it grew through about forty, ten times a second. Nothing is removed or renamed: the phone app reads the same configuration it did, and `d=` stays in the WebSocket protocol for earlier extensions. Static RAM is what it was (161,216 bytes of DRAM, the pinned figure). `docs/rest-api.md` describes the microphone endpoints field by field, including what had been there unwritten (both spellings of a band field, `smoothing`, `attack`, `meta`, `lut`, `env`, the phone's `frame` post, every `source` string, and that a body sent without the form `Content-Type` is not read, which turns a `band=2` reset into a reset of everything). `docs/audio-ws-spec.md` 1.1 states the 500 ms lane release that version 1 said did not exist, and which senders hold a value through it: the extension does, the Android capture app does not yet. Checked on a host by running the old and the new handlers on the same requests (78 assertions), by building every composition, and by the one-field post above on a panel.
+
+### Tools
+
+- **The audio extension holds the knobs through silence, and Live means the panel is being driven** (`tools/patternflow-audio-extension`, 0.2.0). The panel hands a knob back to its encoder 500 ms after the last message that set it, and the extension did not send a frame equal to the last one. So half a second into a silent tab, a paused track or a Gate curve resting on one step, the knobs were let go while the popup said Live: measured, not one message in ten seconds of silence. It now sends an unchanged frame again about every 210 ms (five a second, no gap longer than 223 ms), and the knobs rest where each band rests until Stop. A knob turned by hand during a pause goes back there when its five-second hold ends, and a microphone that is also switched on does not get the knobs during a pause. Owner's decision. Muting a knob hands it back at once (`off=N`), where it waited for the panel's timeout. *Live* used to mean the socket was open, which it also is on a panel whose Audio-React switch is off and which ignores every frame; the extension now asks the panel once when the socket opens (`GET /api/audio`), and off reads *Aud off* with a **Turn on** button. A socket that will not open is explained, where every failure read "WebSocket error": no panel at that address, a panel that does not run the Audio edition, or something that answers and refuses the socket. A silent tab reads "No sound from this tab yet." The popup has one button, Start until a tab is captured and Stop while one is; *Release knobs* is gone (Stop does that), and *Test without audio* holds each knob where its slider is put. The address is taken on Enter, on leaving the field and on Start, where it reconnected on every keystroke (typing `192.168.0.42` tried `192.168.0.4` on the way), and it has a storage key of its own, so an editor tab left open no longer writes an old address back. Closing the captured tab ends the capture and says so, and a restarted service worker no longer shows Idle over a running capture. The frequency labels under the popup's spectrum sit where their frequencies are ("1k" had been over 317 Hz). In the mapping editor the bands are K1 to K4 everywhere, the knob tabs sit directly under the plot, the level axis and the curve's readout are in dB, and the hints are plain sentences ("This band still runs its old boost curve", on a band nobody had touched, is gone). A new install starts with Auto range on and the Smooth curve, as the panel does and as `AUDIO_GUIDE.md` already said; a mapping that is already stored keeps what it has. Switching Auto range off goes back to the windows set by hand, where it replaced all four with wherever the boxes were; dragging a top or bottom edge is what takes them over. Preview puts its scope where the plot is, so the sliders, the curve and the output range it demonstrates stay in reach, where it was a dialog over them. The editor holds together at half a screen's width, and on a touch screen a swipe that starts on an empty part of the plot scrolls the page while one on a box drags it. One model (`mapping.js`) holds the defaults and the level-to-knob arithmetic for the capture, the popup, the editor and the panel's page, where the first three each had a copy. The extension keeps its cream skin. After pulling, press Reload on `chrome://extensions`; the card should read 0.2.0. Reload keeps the stored mapping, so a copy you already had opens as it was, which unless you had changed them is Auto range off and no curve: for the new defaults, press Reset on each of the four knobs in the editor and switch Auto range on, or remove the extension and load it again (which also forgets the panel's address). Checked with the unpacked extension in headless Chromium against a loopback socket and a stand-in for the panel's HTTP side (44 popup checks, and the wire in each state), and against a panel: a tone in the captured tab swung K1 between 0.30 and 0.85, four seconds of silence left the lanes held at their rest value where they used to be released after half a second, switching Audio-React off on the panel turned the popup to *Aud off*, and Stop handed the knobs back to the microphone. Touch was tried only in emulation, and the panel requests only in the Chromium Playwright ships, not in a current Chrome with its local-network permission prompt.
## [3.10.5] - 2026-10-02
diff --git a/docs/audio-ws-spec.md b/docs/audio-ws-spec.md
index f7f332e05..e0cc5b80c 100644
--- a/docs/audio-ws-spec.md
+++ b/docs/audio-ws-spec.md
@@ -60,11 +60,90 @@ was learned: after the first send the buffer is never empty, lanes 1..3
dropped in index order, and knob 4 never moved. One `a=` per frame carries
everything, in order, at a quarter of the traffic.
-Send at your analysis rate (the extension sends per animation frame). There
-is no keep-alive requirement.
+Send at your analysis rate. The extension analyses on a 33 ms timer, so thirty
+messages a second is its ceiling and not its rate: a frame equal to the last
+one is not sent again at once, and one is dropped while another is in flight.
+Measured against a loopback socket, it sends 23–26 a second on music and 5 a
+second while nothing changes (the resend described next).
+
+## Holding a value — the 500 ms release
+
+**A lane is handed back to its encoder 500 ms after the last message that
+set it.** That is what frees the knobs when a tab is closed or a phone walks
+out of range without saying `off`, and it has been in the firmware since the
+first version of this protocol. Version 1 of this document said there was no
+keep-alive requirement, and that was wrong: a client that skips a frame
+because its values did not change loses its lanes half a second into any
+steady passage — silence, a paused track, a gate curve resting at one level —
+and the next thing it sends starts the pattern's motion from wherever the
+encoder had left the knob.
+
+So a client that means to keep driving **resends its last message at least
+every 250 ms**, changed or not. The same goes for `k=`. To let go, stop
+sending, or say `off`.
+
+### What the senders in this tree do
+
+**The extension holds.** A frame that has not changed goes out again once
+180 ms have passed since the last one, which on its 33 ms timer comes to about
+210 ms (measured: five messages a second, no gap longer than 223 ms). While
+its tab is silent or paused the knobs therefore rest where each band rests,
+and stay there until Stop; the sliders of the popup's *Test without audio* are
+held the same way. Two things follow from holding. A knob turned by hand
+during a pause goes back to its resting value when the five-second hold ends.
+And on a panel whose microphone is also switched on, the microphone does not
+get those lanes during the pause: it only drives a lane nobody else has.
+
+A band muted in the extension's editor is a `-` in its slot, and the lane it
+had been driving is handed back at once with `off=N` instead of being left to
+the timeout; with all four muted it sends four `off=N` and then nothing. Stop
+sends `off` and closes the socket. A socket that reopens is told everything
+again in its first frame. Everything the extension sets, it sets with `a=`,
+the test sliders included (`a=0.800,-,-,-`, a `-` for each knob nobody has
+moved). Nothing in this tree sends `k=` or `d=` any more; both stay in the
+table above and the firmware takes both, for earlier builds of the extension
+and for clients outside this tree.
+
+**The Android capture app does not hold yet.**
+`tools/patternflow-audio-android` still has the change-only skip the
+extension had (`sendLanes()` in `DeviceLink.kt`: `if (body == lastBody)
+return`). Its lanes are released 500 ms into a pause or a steady passage, and
+the next frame it sends starts from wherever the encoders, or the microphone,
+left the knobs. For whoever maintains the app: keep the time of the last send
+beside `lastBody` and send the same body again when 250 ms have passed. It
+already forgets `lastBody` when the socket opens. This was outside the change
+that corrected this document, and it is the thing to fix before filming a
+track with a quiet passage in it.
+
+## The switch on the panel
+
+The device accepts the connection, and every message, whether or not
+Audio-React is switched on (the `AUD` row of the panel's NETWORK screen;
+`audioRuntime` in `GET /api/audio` and `/api/status`). Switched off, the
+messages are parsed and nothing is driven — an open socket is not evidence
+that the panel is listening. A client that wants to know reads
+`audioRuntime` over HTTP, on port 80 of the same host whatever port its
+socket uses ([`rest-api.md`](rest-api.md#audio-react-audio-edition)).
+
+The extension asks once each time its socket opens (`GET /api/audio`, four
+seconds to answer), and once more each time its popup is opened while the
+socket is open. `false` is shown as *Aud off* with a **Turn on** button, which is
+`POST /api/audio` with `on=1`; no answer, or a `404` from firmware without the
+route, is "not known" and nothing is claimed either way. It does not poll:
+the panel serves one HTTP connection at a time.
## Version history
+- **1.1** (unreleased) — no change on the wire, and nothing for the firmware
+ to do: old and new firmware behave the same. Written down: a lane is
+ released 500 ms after its last message, so a steady value has to be resent
+ (version 1 said no keep-alive was needed); the extension's timer is 33 ms,
+ not the animation frame, and thirty a second is its ceiling; the socket
+ accepts messages while Audio-React is switched off and ignores them. The
+ extension (0.2.0) now keeps to it: it resends an unchanged frame, hands a
+ muted band's lane back with `off=N`, sends its test sliders as `a=`, and
+ asks the panel over HTTP whether it is listening. The Android app does not
+ resend yet.
- **1** — first written contract: `a=` / `k=` / `d=` / `off=N` / `off`,
lane semantics, the unknown-prefix rule, the one-in-flight pacing rule.
Matches firmware 3.8.0 (Audio edition v0.3.1 onward) and the extension as shipped in
diff --git a/docs/rest-api.md b/docs/rest-api.md
index 05a4ff889..f1bb5439c 100644
--- a/docs/rest-api.md
+++ b/docs/rest-api.md
@@ -385,14 +385,91 @@ Scale constants live in the same file and are the contract for anything converti
Gated on `"audio-in"` in `caps`; the page is `/audio-in`. Settings persist in
NVS. The live shaping UI polls the same endpoints documented here — there is
-nothing it can do that a script cannot.
+nothing it can do that a script cannot. The phone capture app is a second
+client of them: it reads the configuration, and posts what it hears so the
+page has something to draw on a panel with no microphone.
+
+Every level here — `inMin`, `inMax`, `levels`, `env`, `spectrum` — is **linear
+amplitude**, not decibels, and not clamped: a loud pure tone reads well above
+1. The page draws a dB axis and converts at its own edge.
| Endpoint | Meaning |
|---|---|
-| `GET /api/audio-in` | Full state: `micOn`, `micGain` (input gain, 1..16, applied to mic samples before analysis - raw fields stay unscaled), `autoRange` (each band self-normalizes against its tracked floor/peak envelope; on by default), `source` (`"off"`, `"pdm"`, `"pdm (no mic - data pin idle)"`, `"synth"`), raw `rawPeak`/`rawDc`, per-band config (`hzMin`/`hzMax`/`inMin`/`inMax`/`gain`/`outMin`/`outMax`/`knob`/`muted`), `hzRange`, and a 40-bucket log `spec`. |
-| `GET /api/audio-in?levels=1` | The polling shape: `levels`, `levelsN` (the level normalized inside the band's tracked range - what the mapping consumes in auto mode), `outputs`, `spectrum`, `dropped` and nothing configurable — the page owns the config after reading it once. |
-| `POST /api/audio-in` | `mic=0/1` switches the whole feature (installs/releases the I2S driver). `micGain=1..16` sets the input gain; `auto=0/1` switches per-band auto-ranging. `band=N` plus any subset of the band fields updates one band; partial updates are the point, so a dragged handle cannot clobber the other three. |
-| `POST /api/audio-in/reset` | Back to the measured defaults. |
+| `GET /api/audio-in` | The configuration, read once: `source` (below), `micOn`, `micGain` (input gain, 1..16, applied to mic samples before analysis - raw fields stay unscaled), `autoRange` (each band self-normalizes against its tracked floor/peak envelope; on by default), `smoothing` and `attack` (the two glide speeds, 0.05..0.9: falls and rises), `bands` (four, below), `hzRange` (`[31.25,8000.00]`, the bounds a band is held to; two decimals since 1.5). Also carried, for scripts and for diagnosing a microphone: `rawPeak`/`rawDc` (the last window before gain), `windows` (read since boot), `level` and `out` (four raw band levels and what they map to) and `spec`, a 64-bucket log spectrum. |
+| `GET /api/audio-in?levels=1` | The polling shape, no configuration in it: `source`, `ext`, `rawPeak`, `rawDc`, `dropped`, `levels` (four damped band levels), `levelsN` (the level normalized inside the band's tracked range - what the mapping consumes in auto mode; 0 while that band's gate is closed), `outputs` (what each band maps to, 0..1), `env` (four `{lo,hi}`, the tracked range — present only in auto mode or with `ext`), and `spectrum` (64 log-spaced buckets across `hzRange`). A poll counts as someone watching for 2.5 s, which the phone app reads to decide how often to post; add `idle=1` to a poll that should not count. |
+| `POST /api/audio-in` | Any subset of the settings; a request changes only the fields it carries, and answers `{"ok":true}`. `mic=0/1` switches the whole feature (installs/releases the I2S driver). `micGain=1..16`, `auto=0/1`, `smoothing` and `attack` (0.05..0.9). Band fields in either spelling below. `frame=…` is the phone's monitor frame (76 numbers: 4 levels; 4 × lo,hi; 64 buckets): nothing else in that request is read, nothing is saved, and the answer adds `watch` — `{"ok":true,"watch":true}` while a page is watching, `false` when none is. |
+| `POST /api/audio-in/reset` | The firmware's measured defaults. With no argument: all four bands and their curves, `smoothing`, `attack` (since 1.5) and `micGain` — not the microphone switch, not `autoRange`. With `band=N` (0..3, since 1.5): that band and its curve only, and the answer names it, `{"ok":true,"band":N}`. Any other `band` is `400` and resets nothing. Firmware before 1.5 ignores `band` and resets everything; the missing `band` in its answer is how to tell. |
+
+**Band fields.** Each of `bands[0..3]` has `hzMin`/`hzMax` (what it listens
+to), `inMin`/`inMax` (the level window mapped onto the knob in manual mode),
+`gain` (the response exponent, 0.2..4, used while the band has no curve
+table), `outMin`/`outMax` (where the knob rests and peaks, 0..1 each;
+`outMax` below `outMin` is a knob that falls as it gets louder), `knob`
+(0..3; two bands may name one knob and the lower band wins it), `muted`,
+`meta` (up to 31 bytes the editor stores to describe its curve; echoed
+verbatim, never parsed) and `lutSet` (read-only: the band runs a stored curve
+table instead of `gain`).
+
+To write them there are two spellings, and both take any subset:
+
+- `band=N` plus the bare names (`band=2&inMax=0.5`) — one band per request.
+ A `band` outside 0..3 is `400 {"ok":false,"error":"band must be 0-3"}` and
+ nothing in the request is applied.
+- the names with the band appended (`inMax2=0.5&hzMin0=62&hzMax0=375`) — any
+ number of bands per request. Since 1.5 a band is applied when **any** of
+ its fields is present; before, only a request carrying that band's `hzMin`,
+ `lut`, `knob`, `muted` or `outMin` was, and anything else was answered
+ `{"ok":true}` and ignored.
+
+`lut` (write-only) is the curve table: exactly 33 integers 0..255, commas
+only. An empty `lut` clears the table and the band goes back to `gain`; a
+value that is not exactly 33 integers is ignored and the answer is still
+`{"ok":true}`. The firmware does not tie `meta` to `lut` — a client that
+names a curve sends its table in the same request.
+
+Two pairs are settled together after a request's fields are in, whichever
+half arrived: `hzMin`/`hzMax` are held to `hzRange`, put in order and kept at
+least one analysis bin (31.25 Hz) apart; `inMin`/`inMax` are held to 0..1 with
+`inMax` at least 0.01 above `inMin`. One half sent alone can therefore move
+the other — send both halves of a pair when either changes. The bin's width is
+kept only where the range has room for it: a low edge within 31.25 Hz of the
+top is stored as sent, and from about 7984 Hz up the band covers no analysis
+bin, so it reads 0 and its knob rests at `outMin`.
+
+A value that is not a finite number — `nan`, `inf`, one too large for a
+32-bit float — is ignored: the field keeps what it had, and the answer is
+still `{"ok":true}`. A `frame` with one in it is dropped whole, like a frame
+of the wrong length. Before 1.5 such a value was stored and printed back as
+`nan`, which is not JSON: the configuration could not be parsed again until
+the field was overwritten or reset. One that is already stored is replaced at
+boot — a band holding one goes back to its defaults (its curve is kept), and
+so do `micGain`, `smoothing` and `attack`. Text that does not start with a
+number is still read as 0.
+
+**Send the form `Content-Type`.** Like every route on this server, these read
+a request body as arguments only when it is
+`application/x-www-form-urlencoded` (or multipart). `curl -d` sends that by
+itself; a browser's `fetch` with a string body does not (it says
+`text/plain`, and the body arrives as one argument named `plain`). The
+settings POST then changes nothing and answers `{"ok":true}`. The reset is
+the one that bites: `band=2` in a body the server did not read is a reset
+with no `band`, which resets all four. Put the header on, or put `band` in
+the query string, and check that the answer names the band.
+
+**`source`** says what the input is, and the strings are the contract:
+
+| `source` | |
+|---|---|
+| `off` | The switch is off. The analysis is parked and the poll's numbers are whatever the last window left — not zeroed. |
+| `pdm` | The microphone is delivering samples. The only state in which it drives the knobs. |
+| `pdm (no mic - data pin idle)` | The driver is running but the data pin sits at a rail: no microphone, or no data wire. Nothing is driven. |
+| `synth` | The switch is on and the driver is not running — it failed to install, or has not yet (the first quarter second after `mic=1`). |
+| `synth (mic stalled)` | The driver is running and has stopped returning samples. |
+| `phone` | `?levels=1` only, with `ext:true`: the switch is off and the phone app posted a frame in the last three seconds. `levels`, `env` and `spectrum` are then the phone's, already normalized 0..1 on its own scale; `levelsN` and `outputs` are still the microphone's and describe nothing on screen. |
+
+In the two `synth` states the analysis runs on a built-in three-tone test
+signal, which is what the poll then reports. Since 1.5 that signal no longer
+reaches the knobs.
`micDropped` in `/api/status`'s `audioIn` block counts capture hops discarded
because analysis fell behind; flat is healthy, climbing means overload.
@@ -400,8 +477,11 @@ because analysis fell behind; flat is healthy, climbing means overload.
### Audio-React (Audio edition)
Gated on `"audio"` in `caps`. The switch that lets the browser extension and
-the phone capture drive the knobs — the same one the panel's NETWORK screen
-toggles — from the console's `/audio-in` bar.
+the phone capture drive the knobs. It is one switch with three handles: the
+`AUD` row of the panel's NETWORK screen, the *Browser extension and phone
+app* row under Sources on the console's `/audio-in` page, and these two
+routes. The browser extension reads it once each time its socket opens and
+offers to turn it on ([`audio-ws-spec.md`](audio-ws-spec.md#the-switch-on-the-panel)).
| Endpoint | Meaning |
|---|---|
@@ -535,7 +615,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.
+- **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.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*.
diff --git a/firmware/bundles/audio/features_local.h b/firmware/bundles/audio/features_local.h
index 39bcc843f..8f36d9dda 100644
--- a/firmware/bundles/audio/features_local.h
+++ b/firmware/bundles/audio/features_local.h
@@ -12,13 +12,16 @@
// directions over UDP. Was core until it was pointed out that
// "needs no infrastructure" is not a test anyone can apply
// evenly — OSC is sound integration and belongs here.
-// audio The browser path: /audio in the device console, plus the
-// websocket the Chrome extension speaks. Four FFT bands off a
-// tab or a mic, driving the four knobs.
+// audio The browser path: the websocket on port 81 that the Chrome
+// extension and the phone app speak, and the switch that lets
+// them drive (AUD on the NETWORK screen, /api/audio). The client
+// analyses its own sound into four bands and sends the four knob
+// values; this feature serves no page.
// audio_in On-board sound. A PDM microphone on the panel itself, so the
// thing works with no computer in the room at all. Six of seven
// people asked for this and it was the single most-wanted item
-// in the survey — more than every other option combined.
+// in the survey — more than every other option combined. Its
+// page is /audio-in, where both inputs are switched.
//
// Deliberately absent: the show player, weather, MQTT. This firmware is for
// people who point sound at a panel. If you want sequences or a broker, that
diff --git a/firmware/patternflow/console/README.md b/firmware/patternflow/console/README.md
index 502a80e6f..2b60139d1 100644
--- a/firmware/patternflow/console/README.md
+++ b/firmware/patternflow/console/README.md
@@ -19,8 +19,9 @@ whenever you save a page, so a tab left open reloads itself onto your edit
the way a real one does after a firmware update. To see the console the way
a phone on the panel's hotspot does, `--slow` (gzip, 0.4 s round trips,
5 KB/s, one request at a time); `/mock?hotspot=1` and `/mock?busy=update`
-set what `/api/status` says; `--chrome404` runs every page on its fallback.
-The script's docstring has the rest.
+set what `/api/status` says; `/mock?audio=…` sets what the panel hears
+(below); `--chrome404` runs every page on its fallback. The script's
+docstring has the rest.
When you are done:
@@ -49,11 +50,9 @@ CI checks the two stay in sync.
| `clock.html` | `/clock` | `features/clock/core_clock_http.h` | `features/clock/clock_index.h` |
The list `console_pages.py` splices is `PAGES` at the top of that script — add a
-row there when you add a page. `audio-in.html` is itself generated, from the
-browser extension's mapping editor, by `firmware/toolchain/build_audio_in_page.py`;
-edit the extension, run that, then `console_pages.py build`. CI runs
-`build_audio_in_page.py --check`, so a page that was not rebaked after an
-editor change fails there rather than shipping stale.
+row there when you add a page. A file here whose name starts with an
+underscore is a source, not a page. `audio-in.html` is the one page you do
+not edit: it is assembled from other files (below).
Pages under `src/` belong to the core and may not name a feature — not in a
nav row, not in a sentence (see `docs/EDITIONS.md`). Pages under `features/`
@@ -68,6 +67,63 @@ is editable the same way. The chrome draws itself inside a shadow root
page, so do not style or query its insides. What a page may use is
`window.PF`, below.
+## The page that is assembled: `/audio-in`
+
+`audio-in.html` is generated. Edit what it is made from, and
+`console_pages.py build` assembles it before it bakes the headers (the step
+is `firmware/toolchain/build_audio_in_page.py`; its docstring has the detail).
+`console_serve.py` does not read the generated file: it assembles the page
+from these sources on every request, with the same code, so here too it is
+save and refresh. A source the build would refuse shows as the build's own
+message instead of a page.
+
+| source | what it is |
+| ------ | ---------- |
+| `tools/patternflow-audio-extension/editor.html`, `editor.css`, `editor.js`, `mapping.js` | the mapping editor and its model, shared with the browser extension: one copy, so the two cannot drift. From `editor.html` only what is between its two `EDITOR BODY` lines comes here (the extension's own source bar stays behind); from `editor.css` and `mapping.js`, what is above their `EXTENSION ONLY` line |
+| `_audio_in_bar.html` | the panel's frame around the editor: the page heading, the Sources list (the microphone with its gain, the browser path's switch) and the footer, with a line saying `` where the editor's body goes |
+| `_audio_in.css` | the panel's styles, applied after the editor's: the console's base block (the editor brings layout and no colours), then the editor's parts dressed as the console's |
+| `_audio_in_adapter.js` | `window.PFAdapter`, the editor's one way out, spoken over `/api/audio-in`. The contract it keeps is written at the top of `editor.js` |
+
+Comment those files as much as they need. Whole-line comments, indentation
+and blank lines are dropped as the page is assembled, which is a quarter of
+its weight on the wire. Nothing parses the JavaScript to do it, so the build
+refuses the few things that would make it unsafe and names the line; the one
+you might meet is a template literal that runs across lines. Line numbers in
+the browser's console are the generated page's.
+
+It is the heaviest page in the console, so its size is pinned: `BUDGET` in
+`build_audio_in_page.py`, in bytes of the stamped page gzipped as the panel
+sends it. `console_pages.py build` prints how much is spare. CI fails when the
+page is over it, and when `audio-in.html` is not what its sources assemble to
+(`build_audio_in_page.py --check`; `console_pages.py check` says so too).
+
+The page is an editor, so on the mock it has a panel that behaves. Its
+endpoints (`/api/audio-in`, `/api/audio-in/reset`, `/api/audio`) are the one
+part of `console_serve.py` that is a port of the firmware's handlers and not
+a fixture: a request changes only the fields it carries, the two pairs settle
+as they do on a panel, a curve table that is not 33 numbers is ignored with
+`{"ok":true}`, a POST without the form `Content-Type` has no arguments at
+all, and replies have the handler's field order and decimals. The sound is
+made up; what is done with it is not. What the panel hears is a state you
+pick, and each address lands on `/audio-in`:
+
+| `/mock?audio=` | the panel |
+| -------------- | --------- |
+| `music` | microphone on, a room with music in it (the default) |
+| `silence` | on, a quiet room: every gate shut, every knob at rest |
+| `off` | switched off after music: the poll's numbers are the last window's, not zeros |
+| `fresh` | nobody has configured it: off, nothing heard, the factory mapping |
+| `nomic` | on, nothing on the data pin: `source` is `pdm (no mic - data pin idle)` |
+| `stalled` | on, the microphone stopped answering: `synth (mic stalled)`, and the numbers are the test tones |
+| `phone` | off, the phone app posting monitor frames: `phone`, `ext:true`, its own units |
+| `ext` | off, the browser extension connected: `audioClients` is 1 |
+| `noread` | the configuration read gets no reply; the poll and every POST still answer |
+
+The switches on the page work from any of them. A build can also have the
+microphone without the browser path: `/mock?caps=patterns,params,sleep,audio-in`,
+where `/api/audio` is a 404. When a handler in `features/audio_in/` or
+`features/audio/` changes, the port in `console_serve.py` changes with it.
+
## The look
The console is light unless someone picks dark. The choice is the toggle in
@@ -202,3 +258,7 @@ Anything that lives in C++ — whether an upload actually parses, what the
panel does when a show plays, whether a Wi-Fi switch really reconnects — is
not modelled. A page that works against the mock still has to be tried on a
real device before you believe it.
+
+The one exception is `/audio-in`'s endpoints, which are ported from the
+firmware (above). Even there the microphone is invented: what a real one
+does in a real room is still only on a panel.
diff --git a/firmware/patternflow/console/_audio_in.css b/firmware/patternflow/console/_audio_in.css
new file mode 100644
index 000000000..2d7d897b6
--- /dev/null
+++ b/firmware/patternflow/console/_audio_in.css
@@ -0,0 +1,83 @@
+/* /audio-in, the panel's half: its styles. A source, not a page:
+ firmware/toolchain/build_audio_in_page.py puts this after the editor's own
+ rules (tools/patternflow-audio-extension/editor.css, down to its EXTENSION
+ ONLY line) in console/audio-in.html. Edit it here, then run
+ console_pages.py build. Comments and indentation do not travel.
+
+ The editor brings layout and no colour: every colour it uses is a
+ variable, and this file is where the panel gives them values. So the page
+ is a console page: the console's base block (README, "The look"), then the
+ editor's parts dressed as the console's. The extension keeps its own skin
+ below the EXTENSION ONLY line of its sheet; nothing here reaches it. */
+
+/* Console base: the same block opens every core page. Light in :root (the chrome's
+ injected light palette holds these same values: keep the two in step),
+ dark under html[data-theme=dark]. No web fonts: the hotspot has no internet.
+ Two names are the editor's own and stand on the console's: its plot and
+ cards are panels, its tags the ghost grey. They are set here and not in
+ the chrome, which is the core's. The canvases read all of them fresh on
+ every paint, and --led, --rule and --ink are taken apart for see-through
+ paint, so those three stay written #rrggbb. */
+:root{color-scheme:light;--cream:#F4EFE6;--panel:#FFFCFA;--ink:#1A1814;--muted:#625C51;--faint:#7D7668;--rule:#DDD5C6;--rule-soft:#E8E1D4;--ghost:#E3DCCE;--led:#FF5C2E;--bad:#B83C1C;--ok:#2B7A4B;--warn:#85600E;
+--sans:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;--mono:ui-monospace,'Cascadia Mono','SF Mono',Menlo,Consolas,'Roboto Mono',monospace;--field:var(--panel);--cream-2:var(--ghost)}
+html[data-theme=dark]{color-scheme:dark;--cream:#0C0B09;--panel:#131110;--ink:#EDE7DB;--muted:#A39B8A;--faint:#7A7364;--rule:#2B2721;--rule-soft:#1F1C18;--ghost:#2A2620;--bad:#FF7A55;--ok:#57B87F;--warn:#D9A03F}
+[hidden]{display:none!important}
+body{margin:0;background:var(--cream);color:var(--ink);font:15px/1.5 var(--sans);-webkit-font-smoothing:antialiased}
+a{color:inherit}:focus-visible{outline:2px solid var(--led);outline-offset:2px}
+.wrap{max-width:1080px;margin:0 auto;padding:20px 16px 48px}
+h2{margin:14px 0 0;font-size:13px;font-weight:600;color:var(--muted)}
+.msg,.sub{font:12px var(--mono);color:var(--faint)}.pf-ok{color:var(--ok)}.pf-err{color:var(--bad)}
+.chip{display:inline-block;padding:0 8px;border:1px solid var(--rule);border-radius:99px;font:11px/20px var(--mono);color:var(--muted);white-space:nowrap}
+.chip.ok{border-color:var(--ok);color:var(--ok)}.chip.pf-err{border-color:var(--bad);color:var(--bad)}.chip:empty{display:none}
+footer{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-top:26px;border-top:1px solid var(--rule);font:12px var(--mono);color:var(--faint)}
+footer a{display:inline-flex;align-items:center;min-height:40px;color:var(--muted);text-decoration:none}
+header{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px}
+h1{flex:1;margin:0;font-size:22px;font-weight:600;letter-spacing:-.01em}
+p{margin:0}
+.list{margin:0;padding:0;list-style:none;border-top:1px solid var(--rule)}
+.list>li{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;min-height:48px;padding:6px 0;border-bottom:1px solid var(--rule-soft)}
+/* The three sliders are the browser's own, in ink. The console's drawn range
+ (home.html's Range block and its fill()) is 290 bytes on the wire that this
+ page's budget does not have; the look differs by a thumb. */
+input[type=range]{accent-color:var(--ink)}
+
+/* ── Audio ── */
+/* The editor's parts, as the console's: its buttons, tabs and switches are
+ the small button (.btn.sm), its cards and plot the card, its two headings
+ the section title, its hints the note (and the
+ frame's own notes are its .hint, so there is one rule for both). Type is the console's 12 to 15 px
+ in the sans; mono stays on numbers. Orange is for what is selected or on,
+ never a hover. */
+.ghostBtn,.pvSig,.toggleWrap{padding:0 12px}
+.ghostBtn,.pvSig,.chipCard,.toggleWrap{min-height:32px;border-color:var(--rule);border-radius:8px;background:var(--panel);color:var(--ink);font:500 13px var(--sans);transition:border-color .15s}
+.ghostBtn:hover,.outPreset:hover{border-color:var(--ink);color:var(--ink)}
+.ghostBtn.dan,.ghostBtn.dan:hover{color:var(--bad)}.ghostBtn.dan:hover{border-color:var(--bad)}
+:disabled{opacity:.45;cursor:default}
+.toggleWrap{gap:8px;padding-left:8px}
+.toggleLabel,.hint,.fieldLabel{font-size:13px}.fieldLabel{font-weight:500}
+.toggle{width:28px;height:16px;border-radius:8px;background:var(--faint)}
+.dotK{left:2px;top:2px;width:12px;height:12px}
+.toggleWrap.on .toggle{background:var(--led)}.toggleWrap.on .dotK{left:14px}
+.plotWrap,.panel{border-radius:10px}
+.tag,.preset,.outPreset,.stepsRow button{border-radius:8px}
+.mono,.tagText,.chipCard .mono,.damping .mono,.preset span,.outPreset,.stepsRow{font-size:12px}
+.bandName{font-size:15px}
+.colKicker{font:600 13px var(--sans);letter-spacing:0}
+.outFill{background:var(--ink)}
+/* The extension's knob tabs each carry an orange dot. Here orange marks what
+ is selected or on, and the selected tab already has its orange border. */
+.chipDot{display:none}
+/* Until the editor has its mapping its canvases are the browser's 300 px
+ default, wider than a phone's column. */
+canvas{max-width:100%}
+/* The message slot beside "Mapping": what a save or a read came to. */
+h2 .msg{margin-left:8px;font-weight:400}
+/* The sources. A row is its switch, its state, and what belongs to it. (The
+ microphone's note takes a line of its own inside its row: the frame gives
+ it flex-basis 100% in its style attribute.) */
+.list .hint{flex:1 1 16em}
+/* The chrome says when the panel is unreachable; the page only dims what it
+ can no longer vouch for: the plot, the sources, and the header's sentence
+ about who has the knobs. (The adapter dims the plot the same way while the
+ microphone it would draw is not there.) */
+.pf-offline .plotWrap,.pf-offline .list,.pf-offline .sub{opacity:.5}
diff --git a/firmware/patternflow/console/_audio_in_adapter.js b/firmware/patternflow/console/_audio_in_adapter.js
new file mode 100644
index 000000000..8540875d8
--- /dev/null
+++ b/firmware/patternflow/console/_audio_in_adapter.js
@@ -0,0 +1,450 @@
+// /audio-in, the panel's half: window.PFAdapter. A source, not a page:
+// firmware/toolchain/build_audio_in_page.py puts it in console/audio-in.html
+// where the extension's editor.html loads editor-adapter.js. Edit it here,
+// then run console_pages.py build. Whole-line comments and indentation do not
+// travel; a template literal has to stay on one line (the build says so).
+// Every byte that is not a comment is sent over the panel's Wi-Fi, and the
+// page has a budget (BUDGET in the builder): this file is written short.
+//
+// The editor (editor.js, shared with the extension) reaches the panel only
+// through this object; the contract it keeps is written at the top of
+// editor.js, and the API it speaks is docs/rest-api.md, "Microphone". Four
+// jobs live here:
+//
+// - scale conversion. The firmware measures, gates and maps in LINEAR
+// amplitude (every constant in core_audio_in_map.h was measured on that
+// scale and stays); the editor's level axis is dB so boxes drag like
+// hearing works. This file converts both ways at the boundary.
+// DB_FLOOR/DB_SPAN are the calibration: a quiet room sits about 0.15 up
+// the axis, listening-volume peaks about 0.9. The phone app mirrors the
+// two numbers, so they do not move.
+//
+// - reading. Nothing is saved until the panel's own mapping has been read
+// (`ack` is null until then): an editor holding defaults must never write
+// them over a mapping it failed to fetch. The read is retried until it
+// answers.
+//
+// - saving. ONE path for everything this page writes (the mapping, the
+// microphone switch, its gain): `cur` is what the page wants, `ack` what
+// the panel last acknowledged, both as the text of each POST field. A
+// save sends the fields that differ and nothing else, one request at a
+// time, so two tabs do not write each other's stale copy back and an
+// Attack nudge is `attack=0.650`, not forty arguments.
+//
+// - the sources. Who can have the knobs, and how each stands: the
+// microphone's state is the `source` of every levels poll, the
+// extension's switch and socket count are what the chrome's status
+// delivers, kept fresh by the chrome's shared poller (PF.watchStatus).
+// The page fetches neither by itself.
+//
+// Every request rides the console's lane (README, "Writing a page"): the
+// levels and the read are PF.poll, never a timer of this file's own.
+(function () {
+ var DB_FLOOR = -45, DB_SPAN = 47;
+ var $ = (id) => document.getElementById(id);
+ var clamp01 = PFMap.clamp01;
+ var dbn = (x) => clamp01((20 * Math.log10(Math.max(Number(x) || 0, 1e-4)) - DB_FLOOR) / DB_SPAN);
+ var lin = (v) => Math.pow(10, (clamp01(v) * DB_SPAN + DB_FLOOR) / 20);
+ var say = (text, kind) => PF.say(text, kind || 'info', $('barMsg'));
+
+ // A curve's name, as the panel stores it beside the table (`meta`, 31
+ // bytes, never parsed by the firmware): a, s:N, p:preset, b: six
+ // hundredths. The shapes are the model's own (PFMap.PRESETS), not copied.
+ function encodeMeta(curve) {
+ var c = curve || 0;
+ return c.type === 'steps' ? 's:' + c.n
+ : c.type === 'arch' ? 'a'
+ : c.type !== 'bezier' ? ''
+ : (PFMap.PRESETS[c.id] || 0).type === 'bezier' ? 'p:' + c.id
+ : 'b:' + [c.y0, c.y1, c.p1x, c.p1y, c.p2x, c.p2y].map((v) => Math.round(clamp01(v) * 100));
+ }
+ function decodeMeta(m) {
+ var q = m.slice(2).split(','), n = parseInt(q[0], 10) || 2, p = PFMap.PRESETS[q[0]] || 0;
+ q = q.map((v) => (parseInt(v, 10) || 0) / 100);
+ return m === 'a' ? { type: 'arch', id: 'arch' }
+ : /^s:/.test(m) ? { type: 'steps', id: n === 2 ? 'gate' : 'steps', n: Math.max(2, Math.min(8, n)) }
+ : /^p:/.test(m) && p.type === 'bezier' ? Object.assign({}, p)
+ : /^b:/.test(m) && q.length === 6 ? { type: 'bezier', id: 'custom', y0: q[0], y1: q[1], p1x: q[2], p1y: q[3], p2x: q[4], p2y: q[5] }
+ : null;
+ }
+
+ // ── what the panel has, and what the page wants ───────────────────────
+ //
+ // Field name -> the text a POST carries for it. `ack` is null until the
+ // panel's configuration has been read; while it is, nothing saves.
+ var ack = null, cur = {}, luts = [];
+ var hz = [31.25, 8000];
+ var frameFn, cfgFn, levels;
+ // One write to the panel at a time, in the order they were asked for.
+ var tail = Promise.resolve();
+ // `tries` counts a save's failures, `told` is the failure the slot is showing.
+ var tries = 0, told = '', leaving = false;
+ // The sources: the last poll's `source` and `ext`, when the microphone
+ // switch last moved, the browser path's three facts from the status
+ // (`hasAud` is undefined until one has come), and how many switches of that
+ // path this page has on their way to the panel.
+ var source = '', ext = false, micAt = 0;
+ var hasAud, aud = false, clients = 0, busy = 0;
+
+ // A band's numbers and the decimals each is sent with (the firmware's own
+ // print precision). The first six are the three pairs, each beside its mate.
+ var FIELDS = [['hzMin', 1], ['hzMax', 1], ['inMin', 5], ['inMax', 5], ['outMin', 3], ['outMax', 3], ['gain', 3]];
+
+ // The editor's mapping as POST fields. The acknowledged copy is made with
+ // this too, from the mapping as the editor will hold it, so the first edit
+ // sends that edit and not every band the round trip through dB moved by a
+ // digit. Beside each curve's name, its table, baked if the editor has none.
+ function ser(cfg, to) {
+ to.auto = cfg.autoRange ? '1' : '0';
+ to.smoothing = cfg.smoothing.toFixed(3);
+ to.attack = cfg.attack.toFixed(3);
+ cfg.bands.forEach((b, i) => {
+ FIELDS.forEach((f) => { to[f[0] + i] = (f[1] > 4 ? lin(b[f[0]]) : b[f[0]]).toFixed(f[1]); });
+ to['muted' + i] = b.muted ? '1' : '0';
+ to['meta' + i] = encodeMeta(b.curve);
+ luts[i] = (!b.curve ? [] : b.lut && b.lut.length === 33 ? b.lut : PFMap.bakeLut(b.curve))
+ .map((v) => Math.round(clamp01(v) * 255));
+ });
+ }
+
+ // The fields that differ, as a POST body ('' = the panel has everything),
+ // and in `sent` what each was sent as. A pair goes whole when either half
+ // moved (hzMin/hzMax, inMin/inMax, outMin/outMax): the range that was
+ // touched is stored as the page shows it. That matters where the page's
+ // copy is not the panel's to the digit: the model widens a window narrower
+ // than its minimum and swaps an output range stored reversed (only another
+ // API client can make either), and the half that was not dragged would
+ // otherwise stay as the panel had it, under a page showing something else.
+ // The price is two tabs on the two edges of ONE box: the later one writes
+ // the box it shows. A curve's name never goes without its table, nor a
+ // table without its name.
+ function diff(sent) {
+ var out = [];
+ var put = (k) => { out.push(k + '=' + encodeURIComponent(cur[k])); sent[k] = cur[k]; };
+ var one = (k, mate) => { if (cur[k] !== ack[k] || cur[mate] !== ack[mate]) put(k); };
+ ['mic', 'auto', 'micGain', 'smoothing', 'attack'].forEach((k) => one(k));
+ for (let i = 0; i < 4; i++) {
+ FIELDS.forEach((f, n) => one(f[0] + i, n < 6 && FIELDS[n ^ 1][0] + i));
+ one('muted' + i);
+ if (cur['meta' + i] !== ack['meta' + i]) { put('meta' + i); out.push('lut' + i + '=' + luts[i]); }
+ }
+ return out.join('&');
+ }
+
+ // A POST the panel's parser reads (no form Content-Type, no arguments), that
+ // cannot hang the queue (twelve seconds, where the browser can count them),
+ // and that outlives the page when it is the last one.
+ var post = (url, body) => fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: body, keepalive: leaving,
+ signal: AbortSignal.timeout ? AbortSignal.timeout(12000) : undefined
+ }).then((r) => {
+ if (!r.ok) throw Error(r.status);
+ return r.json();
+ });
+
+ // Send what differs. PF.dirty stays up until the panel has acknowledged
+ // all of it, so the chrome never reloads the page over an unsaved edit. A
+ // save that failed is tried once more, when the next levels poll shows the
+ // panel answering; after that it waits for the next change, and says so.
+ function save() {
+ return tail = tail.then(() => {
+ var sent = {}, body = ack && diff(sent);
+ PF.dirty = !!body;
+ if (!body) return told && say(told = '');
+ return post('/api/audio-in', body).then(() => {
+ Object.assign(ack, sent);
+ if (sent.mic) { micAt = Date.now(); levels.set(100); levels.now(); }
+ if (told) say('saved', 'ok');
+ told = '';
+ tries = 0;
+ PF.dirty = !!diff({});
+ draw();
+ }, () => {
+ say(told = ++tries < 2 ? 'not saved yet: the panel did not answer. Trying again.'
+ : 'not saved: the panel did not take it. Change it again to retry.', 'err');
+ // The slot is at the top of a long page: giving up is also a toast.
+ if (tries > 1) PF.say(told, 'err');
+ });
+ });
+ }
+ function change() { tries = 0; PF.dirty = true; return save(); }
+
+ // On the way out there is no waiting for the queue: whatever differs goes
+ // now, once, and is taken as sent. editor.js flushes its own waiting save
+ // from pagehide as well; this listener is registered first, so that save
+ // finds `leaving` set and goes the same way.
+ function bye() {
+ var sent = {}, body = ack && diff(sent);
+ if (!body) return;
+ post('/api/audio-in', body).catch(() => {});
+ Object.assign(ack, sent);
+ }
+ addEventListener('pagehide', () => { leaving = true; bye(); });
+ addEventListener('pageshow', () => { leaving = false; });
+
+ // ── reading ───────────────────────────────────────────────────────────
+
+ // The panel's configuration reply, as the editor's mapping; and from the
+ // same reply the acknowledged copy and the device's own controls. A curve
+ // is taken only where the panel runs its table (lutSet): a name without a
+ // table is a band running its gain, and is shown as that.
+ function take(j) {
+ hz = j.hzRange || hz;
+ var cfg = PFMap.normalizeConfig({
+ autoRange: j.autoRange, smoothing: j.smoothing, attack: j.attack,
+ bands: j.bands.map((b) => Object.assign({}, b, {
+ inMin: dbn(b.inMin), inMax: dbn(b.inMax),
+ curve: b.lutSet ? decodeMeta(b.meta) : null
+ }))
+ }, { hzMin: hz[0], hzMax: hz[1] });
+ ack = {};
+ ser(cfg, ack);
+ ack.mic = j.micOn ? '1' : '0';
+ ack.micGain = j.micGain.toFixed(1);
+ cur = Object.assign({}, ack);
+ source = j.source;
+ PF.dirty = false;
+ $('micGain').value = ack.micGain;
+ $('micToggle').disabled = $('micGain').disabled = false;
+ draw();
+ return cfg;
+ }
+
+ // Read the configuration until the panel answers with one: a reply without
+ // four bands (an error page, another device) is not one. On the console's
+ // lane, slower after each miss.
+ function read() {
+ ack = null;
+ say('Reading the panel’s mapping…');
+ return new Promise((done) => {
+ var wait = 2000, rd = PF.poll('/api/audio-in', wait, (j) => {
+ if (j && (j.bands || 0).length === 4) {
+ rd.stop();
+ say('');
+ done(take(j));
+ } else {
+ say('Could not read the panel’s mapping. Trying again.', 'err');
+ rd.set(wait = Math.min(wait * 2, 16000));
+ }
+ });
+ });
+ }
+
+ // The firmware's own defaults: the page carries no table of them. The reply
+ // has no configuration in it, so the mapping is read again and replaces the
+ // editor's copy and the acknowledged one before anything saves (a stale
+ // copy would write the reset away on the next edit); the editor stands
+ // inert while that is open. `body` is 'band=N' or '' for everything.
+ function reset(body) {
+ return tail = tail.then(() => {
+ $('ed').inert = true;
+ return post('/api/audio-in/reset', body).then(read, () => {
+ say('Reset failed: the panel did not take it.', 'err');
+ }).then((cfg) => {
+ $('ed').inert = false;
+ return cfg;
+ });
+ });
+ }
+
+ // ── the levels ────────────────────────────────────────────────────────
+
+ // One poll, as the editor's frame. Levels are drawn only for a source that
+ // is heard: the microphone delivering samples ('pdm') or a phone frame
+ // (`ext`). In every other state the poll's arrays are stale (off), the
+ // test tones (synth) or a dead pin, and none of them is the room.
+ function onLevels(j) {
+ var f = {}, conv;
+ if (!j) {
+ // No answer: ask once a second, not ten times; the chrome says why.
+ levels.set(1000);
+ frameFn(f);
+ return;
+ }
+ ext = j.ext;
+ source = j.source;
+ // The switch as the panel has it (another tab may have moved it), except
+ // around a change of our own: a poll that left before it would undo it.
+ if (ack && cur.mic === ack.mic && Date.now() - micAt > 1500) cur.mic = ack.mic = ext || source === 'off' ? '0' : '1';
+ // As often as there is something new to draw. The microphone's levels
+ // move ten times a second; the phone app posts a frame every 250 ms, on
+ // the one connection this poll also needs. Nothing is drawn from any
+ // other state (off, no microphone, not answering), so a slow poll is
+ // enough to see a switch moved elsewhere, and still inside the 2.5 s the
+ // panel counts a page as watching, which is what keeps the phone app
+ // sending. For a second and a half after the switch moves the poll stays
+ // fast, so "starting" settles quickly.
+ levels.set(ext ? 250 : source === 'pdm' || Date.now() - micAt < 1500 ? 100 : 1500);
+ if (ext || source === 'pdm') {
+ conv = ext ? clamp01 : dbn;
+ f.levels = j.levels.map(conv);
+ f.spectrum = j.spectrum.map(conv);
+ // An envelope only once one is learned: a panel that has heard nothing
+ // sends lo -1.5, hi 0.
+ f.env = (j.env || []).map((e) => ext || e.lo >= 0 && e.hi > e.lo ? { lo: conv(e.lo), hi: conv(e.hi) } : null);
+ if (ext) {
+ // The phone's own axis; levelsN and outputs are the microphone's
+ // even here, so the editor does this frame's arithmetic itself.
+ f.db = [-80, -10];
+ } else if (ack) {
+ // Where the mapper has each level: inside its envelope in auto
+ // range, linearly between the stored edges by hand. Not while a
+ // reset's re-read is open (`ack` is null then): that frame is drawn
+ // from its levels alone.
+ f.outputs = j.outputs;
+ f.pos = j.levels.map((v, i) => {
+ var lo = +ack['inMin' + i];
+ return clamp01(ack.auto > 0 ? (j.levelsN[i] - 0.10) / 0.85 : (v - lo) / (Math.max(lo + 0.01, ack['inMax' + i]) - lo));
+ });
+ }
+ }
+ draw();
+ frameFn(f);
+ if (tries === 1) save();
+ }
+
+ // ── the sources ───────────────────────────────────────────────────────
+
+ var NOTES = {
+ 'off': 'The microphone is off. The mapping can still be edited.',
+ 'not detected': 'No microphone found: the data pin (RX) is idle. Check the DAT lead.',
+ 'not answering': 'The microphone is not answering.'
+ };
+ function flip(id, on) {
+ $(id).classList.toggle('on', on);
+ $(id).setAttribute('aria-pressed', on);
+ }
+ // A chip is its word and how it stands: ' ok' (green), ' pf-err' or ''.
+ function chip(id, text, how) {
+ $(id).textContent = text;
+ $(id).className = 'chip' + how;
+ }
+ function draw() {
+ if (!ack) return;
+ // The microphone, in one word. For a second after the switch goes on the
+ // firmware has not made up its mind (the first reads say synth, and "no
+ // microphone" needs the old levels to decay).
+ var on = cur.mic > 0;
+ var m = !on ? 'off'
+ : Date.now() - micAt < 1500 || /^(off|phone)$/.test(source) ? 'starting'
+ : source === 'pdm' ? 'listening'
+ : source === 'pdm (no mic - data pin idle)' ? 'not detected'
+ : 'not answering';
+ var bad = /^not/.test(m) ? ' pf-err' : '', note = !ext && NOTES[m] || '';
+ flip('micToggle', on);
+ // A fault is in the error colour, on the chip and on the sentence under
+ // the switch: it must not look quieter than "listening".
+ chip('micChip', m, bad || (m === 'listening' ? ' ok' : ''));
+ $('micGainVal').textContent = cur.micGain;
+ $('deviceNote').textContent = note;
+ $('deviceNote').className = 'hint' + bad;
+ // The plot has nothing to draw from a microphone that is not there.
+ $('plotWrap').style.opacity = bad ? 0.5 : '';
+ // The browser row stands in the page from the first paint, its switch
+ // disabled and its chip empty, and goes away only when a status says this
+ // build has no such path: the status comes after the mapping, and a row
+ // arriving then would push the editor down under a finger.
+ $('extRow').hidden = hasAud === false;
+ $('audToggle').disabled = !hasAud;
+ flip('audToggle', aud);
+ chip('extChip', !hasAud ? '' : !aud ? 'off' : ext ? 'phone app' : clients ? 'connected' : 'waiting', aud && ext ? ' ok' : '');
+ // Who has the knobs, in words. On the panel a sender on the socket
+ // outranks the microphone lane by lane (feature_audio_in.h drives only a
+ // lane nobody else has taken), and the page cannot see whether an open
+ // socket is sending: so while one is open the microphone is not named as
+ // the owner, listening or not, and the browser path is only ever
+ // "connected".
+ $('st').textContent = hasAud && aud && clients && !ext ? 'a browser or phone is connected'
+ : (m === 'listening' ? 'the microphone' : ext ? 'the phone app' : 'nothing') + ' has the knobs';
+ }
+ // The browser path's switch and how many sockets are open: the chrome's
+ // status, and kept fresh (its one shared poller, every 5 s), because the
+ // page says "waiting" and "nothing has the knobs" for as long as it is
+ // open, and the usual order is this page first and the extension after.
+ // Subscribed from onFrame, once the mapping is in, and not at load:
+ // subscribing asks for a status at once, and one ahead of the read only
+ // makes the editor a round trip later (nothing is drawn from it before the
+ // mapping is in). The chrome fetches its first status by itself, and on
+ // its lane that lands right behind the read. A reply that was on its way
+ // while a switch of this page's own was being sent is older than the
+ // switch, and is not taken for it.
+ function onStatus(s) {
+ hasAud = (s.caps || []).includes('audio');
+ if (!busy) aud = s.audioRuntime;
+ clients = s.audioClients;
+ draw();
+ }
+
+ $('micToggle').onclick = () => {
+ cur.mic = cur.mic > 0 ? '0' : '1';
+ micAt = Date.now();
+ draw();
+ change();
+ };
+ // The gain shows as it is dragged and is sent when it is let go.
+ $('micGain').oninput = () => {
+ cur.micGain = (+$('micGain').value).toFixed(1);
+ PF.dirty = true;
+ draw();
+ };
+ $('micGain').onchange = change;
+ $('audToggle').onclick = () => {
+ var want = aud = !aud;
+ busy++;
+ draw();
+ tail = tail.then(() => post('/api/audio', 'on=' + (want ? 1 : 0)).then((r) => {
+ aud = r.audioRuntime;
+ }, () => {
+ // A lost request must not leave the switch lying until the next status.
+ aud = !want;
+ say('not switched: the panel did not answer.', 'err');
+ }).then(() => {
+ busy--;
+ draw();
+ }));
+ };
+
+ // The canvases take their colours from the page's variables on each paint,
+ // and a paint comes with a frame. With the microphone off a frame is 1.5 s
+ // away, so a theme change repaints by itself (selectBand is editor.js's:
+ // the plot, the curve, the shapes and the tab glyphs).
+ new MutationObserver(() => { if (frameFn) selectBand(sel); })
+ .observe(document.documentElement, { attributeFilter: ['data-theme'] });
+
+ window.PFAdapter = {
+ loadConfig: read,
+ // Asked once, after the read: the axis and the limits the firmware holds
+ // a band to, so nothing the editor stores snaps back. A window is at
+ // least 0.01 tall in LINEAR amplitude and tops out at linear 1.0; the
+ // model's own 0.02 of the axis applies as well, whichever is stricter.
+ caps: () => ({
+ hzMin: hz[0], hzMax: hz[1], hzGap: 31.25,
+ db: [DB_FLOOR, DB_FLOOR + DB_SPAN],
+ levelMax: dbn(1),
+ top: (lo) => Math.max(lo + PFMap.MIN_WINDOW, dbn(lin(lo) + 0.01)),
+ bottom: (hi) => Math.min(hi - PFMap.MIN_WINDOW, dbn(lin(hi) - 0.01))
+ }),
+ saveConfig: (cfg) => {
+ if (!ack) return;
+ ser(cfg, cur);
+ return leaving ? bye() : change();
+ },
+ onConfig: (fn) => { cfgFn = fn; },
+ onFrame: (fn) => {
+ frameFn = fn;
+ levels = PF.poll('/api/audio-in?levels=1', 100, onLevels);
+ PF.status(onStatus);
+ PF.watchStatus(5000);
+ },
+ resetBand: (i) => reset('band=' + i),
+ // PFConfirm is editor.js's two-press confirmation: the phone app's
+ // WebView has no window.confirm. It says exactly what the panel resets.
+ resetAll: (button) => {
+ if (PFConfirm(button, 'Press again: resets all four knobs, their curves, attack, damping and input gain')) {
+ PF.busy(button, reset('').then((cfg) => { if (cfg) cfgFn(cfg); }));
+ }
+ }
+ };
+})();
diff --git a/firmware/patternflow/console/_audio_in_bar.html b/firmware/patternflow/console/_audio_in_bar.html
new file mode 100644
index 000000000..3a089a1b2
--- /dev/null
+++ b/firmware/patternflow/console/_audio_in_bar.html
@@ -0,0 +1,58 @@
+
+
+
Audio
+
Sources
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ The extension brings its own mapping; the phone app uses the one below. Same switch as AUD on the panel’s NETWORK screen.
+ Get the extension
+
+
+
Mapping
+
For the microphone and the phone app. Changes save to the panel as you make them.
- This band still runs its old boost curve — pick a preset to upgrade it.
- Drag the two handles for a custom shape. Any curve saves as a 33-point table.
-
-
-
-
-
-
- Band 1
- 62 – 375 Hz
-
-
-
-
- Output range
- 0.30 – 0.85
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Mute band
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Preview — Band 1
-
-
-
-
-
-
-
-
-
- input level
- knob output
-
-
-
-
-
-
-
-
-
-
-
+
+
Audio
+
Sources
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+The extension brings its own mapping; the phone app uses the one below. Same switch as AUD on the panel’s NETWORK screen.
+Get the extension
+
+
+
Mapping
+
For the microphone and the phone app. Changes save to the panel as you make them.
+
+
+
+Each box follows the loudness of what it hears. Drag a top or bottom edge to take all four over by hand.
+