ToneCommand GitHub

Docs

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:

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"

Invariants

Break these and the program is a different, worse program.

  1. Nothing reaches hardware without an explicit human confirmation.
  2. Firmware and bootloader messages are unreachable by construction, not by check. Rule zero: never brick a device.
  3. Storing is whitelisted to configured slots and refuses everything else.
  4. Every action is validated against the device reference before it is shown, whatever produced it.
  5. An undo snapshot is taken before any write, automatically.
  6. 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.