Architecture
What this program is shaped like, why, and what is wrong with it. Written 2026-09-01 against 871 tests passing.
The one-sentence version
A local FastAPI server owns the only connection to the FM9 and exposes it over HTTP; a single-page browser UI drives it; an LLM turns sentences into proposed parameter changes that a human confirms before anything is transmitted.
The shape
browser (ui/) server.py fm9/
──────────── ───────── ────
conversation ──── /api/chat ──── planner.converse ────┐
prompt ────────── /api/plan ──── planner.plan ────────┤ backends:
confirm gate ──── /api/apply ─── run_action ──┐ │ openai / cli
sliders ───────── /api/apply ─────────────────┤ │ grok / api
scenes, chain ─── /api/state ─── snapshot ────┤
↓
device.FM9 ──── MIDI SysEx
sim.FM9Sim ──── in-process fake
Everything that reaches hardware goes through run_action, and nothing
reaches run_action without a human pressing TRANSMIT. That is the single
invariant this codebase is organised around.
Layers, in dependency order
devices/axefx3/existing_scalar.py composes the unchanged #297 pure contract
with complete observations, muted original-scene survey and exact typed scalar
output. Host-owned context stays private; only sanitized metadata reaches the
provider. It is not app-registered. server.py stays unchanged at 8,893 lines.
devices/axefx3/scene_observation.py explicitly recalls existing scenes under
external mute, compares complete opaque observations and returns the original
scene only on verified success. It is not app-registered, never rewrites scene
assignments and stops/latches without rollback on uncertainty. The server is
unchanged at 8,893 lines.
devices/axefx3/official_amp_writer.py composes the complete read-only observer
with the bounded existing Gain/Mid edit and one-use output permit. Its exact
official identity is independent of old writers. It is not registered by the
app and preserves every unrelated observed word. server.py stays 8,893 lines.
Physical III adapters acquire the same nonblocking OS lease before MIDI open. Closing attempts both ports and releases ownership even if a dead handle raises. The lock is per-user, independent of temporary roots. Injected test ports do not acquire physical ownership. No forced closure, takeover or second open is attempted.
devices/axefx3/live_observation.py composes the receive-only observation
foundation with a separate literal-GET-only output boundary. It is unregistered,
does not change the ordinary adapter, and checks fresh identity/target before
and after complete reads. No writer, scene recall or UI route is enabled.
This slice leaves server.py unchanged at 8,893 lines.
Recipe firmware warnings recognize one short stable-release label paired with
the same release's valid full build-date label. This affects prose only, never
an adapter identity or write gate. Different full dates and beta labels retain
warnings. This shared fix leaves server.py at8,893 lines.
devices/axefx3/observed_firmware.py names complete observed firmware responses
for status display only. Unknown replies retain their raw version. Recognition
does not select a writer or change its independent identity pin. No routes or
server code are added by this slice.
Interface languages are local data, not AI or device actions. fm9/language.py
serves an asset allowlist including bundled locale files. A single dropdown in
Settings selects a shipped language. The local i18next engine translates registered
initial markup and explicitly marked messages; user input, live device readings
and existing plans are not scanned. English is the source and fallback. Bundled
JSON is matched to its catalog hash and rendered as text. Only the locale choice
is stored in the browser; old imported translation data is not restored. Language
changes cannot alter option values or critical English confirmations. Catalog
and bundled-file checks live in tools/language_packs.py.
Experimental Axe-Fx III reading is isolated in devices/axefx3. The existing
Devices selector creates a separate adapter with a strict final output wrapper;
only product 0x10 basic read forms reach MIDI. It neither inherits FM9 nor
loads its parameter/model/cab catalogs. The shared block-name table is used
only for status labels; unknown IDs remain numeric. snapshot delegates this
context to a read-only status reader rather than the FM9's grid/parameter path.
Preset/scene navigation, sound changes, NAM and account integration are outside
this slice of #270. See docs/AXEFX3-READONLY.md for independent acceptance.
The Devices panel declares preset-name capacity, defaulting to Mark I/512.
Mark II/Turbo explicitly allow 1024 name reads at both output boundaries.
The choice is process-local, never hardware autodetection or a write release.
Shared name scans derive their bound from the adapter and scope their cache
to the selected context/connection/chassis; FM9 retains its 512-name default.
Name-list formatting uses each adapter's slot labels: the III displays wire
numbers directly, while FM9 keeps its existing one-based display numbering.
devices/axefx3/observed_words.py and preset_observation.py validate complete
received words and preserve opaque preset observations. They are unregistered
data/read-callback components, not a live reader or an expansion of the output
gate. Fresh transport provenance and native acceptance remain separate work.
| Layer | Files | Knows about |
|---|---|---|
| Protocol | protocol.py |
SysEx bytes, effect ids, nothing above |
| Transport | device.py, sim.py |
protocol, MIDI ports |
| Model | registry.py, signal_path.py, editbuffer.py |
protocol |
| Reasoning | planner.py, describe.py |
nothing device-specific except the reference text it is handed |
| Policy | server.py |
all of the above |
| Presentation | ui/index.html |
HTTP only |
The direction holds. protocol.py has no idea a planner exists, and
planner.py has no idea what a SysEx byte is. That separation is why the
simulator can stand in for hardware without either side knowing.
What is actually wrong
Ranked by what it costs, not by how ugly it looks.
1. ui/index.html is 249 KB in one file
5,306 lines: 160 KB of JavaScript and 68 KB of CSS inline in one document, with 109 top-level functions in a single flat scope.
The cost is not aesthetic. Three bugs in the last day were of a class a file boundary would have prevented or made obvious:
- Handlers bound to
#knobswhile the graphic EQ lived in#geqbox, so the faders were dead on arrival and 501 tests passed over it. - The same id (
cclear) written twice in one template. - A
[hidden]attribute silently beaten by three differentdisplay: flexrules written hundreds of lines apart.
Everything is in reach of everything, so nothing can be reasoned about locally, and the only way to catch a layout regression is a screenshot.
Fix: split into ui/index.html, ui/app.css, and JavaScript modules by
concern. Served as static files. No behaviour change, mechanical, testable.
2. server.py is 8,893 lines and 123 routes
Every concern in one module: device polling, planning, applying, settings, recipes, sharing, snapshots, profiles, slot admin. Eight pieces of module-level mutable state and two locks whose interaction is not written down anywhere.
The state is the real risk, not the line count:
_lock the device. Held across MIDI round trips.
_settings_lock planner settings. Held across a whole LLM call.
_last_snapshot last good device reading, for offline
_snaps undo / A / B
_profile a loaded foreign rig profile, which OUTRANKS the device
_gig_mode refuses everything but scene changes
_preset_cache slot names
_shared_cache shared recipe map
_synthetic_slots simulated empty slots
_profile outranking the live device is correct and surprising, and it is
enforced by an if at the top of two handlers. Nothing stops a third handler
being written without it.
Fix: split routes into modules by concern; move the mutable state behind a small number of explicit accessors so the precedence rules live in one place rather than in each handler's first three lines.
3. Two lock scopes with no written contract
_settings_lock is held for the duration of an LLM call, which can be four
minutes on a describe-build. Anything else wanting to save settings waits, and
/api/ai-settings returns 409 after 2 seconds rather than blocking. That is
the right behaviour and it is discoverable only by reading three call sites.
Fix: one paragraph at the top of the module saying what each lock covers and what is legal inside it.
4. UNDO does not survive a restart
_snaps is a module-level dict. Restart the server and every undo, A and B
snapshot goes with it, while the edit buffer on the rig keeps the changes they
were the way back from. Found the hard way: a transmit, then a restart, then
an undo that answered "nothing captured in undo" with the changes still on the
hardware.
It is recoverable, because nothing is ever stored without an explicit SAVE, so reselecting the preset discards the buffer. But "reload your preset" is a worse answer than UNDO, and the button implies a promise the process lifetime does not keep.
Fix: write the undo snapshot to disk beside the settings. It is a few dozen parameter values.
5. Presentation logic in server.py
slot_label, state_text, and the plan-shape prose exist to be read by
either a model or a person. They are formatting, sitting in the policy layer.
Harmless today, and the first thing to go wrong when a second client appears.
6. ai_settings.py has grown a second job
1,024 lines. It manages planner settings, and it also now installs software,
edits a third-party YAML config, derives a password and runs brew. Those are
setup concerns wearing a settings module's name.
Fix: fm9/setup_guide.py, leaving ai_settings.py to settings.
What is right, and should not be "fixed"
- One writer. The server owns the MIDI port. Every write is serialised
behind
_lock. This is why two browser tabs cannot corrupt a preset. - The simulator is a real implementation, not a mock. It is why 871 tests run in under a minute with no hardware.
- The failure taxonomy in
planner.py. Transport failure falls through to the next backend; a reply that parses but says nothing useful does not. That distinction is load-bearing and easy to get wrong. validate_actionis a chokepoint. Every path that can produce an action goes through it, including the ones added most recently. There are tests asserting new entry points cannot bypass it.- Tests describe failures, not functions. They are named after what went wrong and the docstring says what it cost. That is why they survive refactors that rename things.
Invariants
Break these and the program is a different, worse program.
- Nothing reaches hardware without an explicit human confirmation.
- Firmware and bootloader messages are unreachable by construction, not by check. Rule zero: never brick a device.
- Storing is whitelisted to configured slots and refuses everything else.
- Every action is validated against the device reference before it is shown, whatever produced it.
- An undo snapshot is taken before any write, automatically.
- Pedal 1 is never touched.
Axe-Fx III existing-preset proposal contract
devices/axefx3/existing_contract.py is a pure proposal contract, currently unused
by the app. It validates a host-qualified scalar surface and all eight original
scene maps without importing a device or executor. Its review-only result does
not contain generic actions or grant firmware, model or write permissions.
Source: ARCHITECTURE.md in the repository.
