MIDI debug tool for the Midi Fighter Twister

A bench for checking MIDI values against the hardware: send one CC at a time and watch the encoder. Use it to confirm a value from config.py on your own unit, or to tell a paint bug from a wire bug.

Chrome only — Web MIDI no daemon involved mirrors mft/config.py

01 · Connection

not connected
animation rates come from incoming MIDI clock; without one, gates and pulses free-run and drift out of phase

02 · Ring brightness — channel 6

The focus marker, RING_CEILING and the focus pulse all depend on this band. If this panel does nothing, none of them work. Ring position is held at full here so there is something to dim.

Channel 6 carries the indicator tables — channel 3's, shifted up by 48: 49–56 gate · 57–64 pulse · 65 = brightness 0% · 95 = brightness 100%. An RGB brightness (18–47) sent here falls below the band and does nothing. The ceiling is the last value that still changes something, not the first that looks bright.

03 · Target encoder

Slot is also the CC number on every channel: bank N is CC N×16 … N×16+15, so slot and control are the same integer. Selected: slot 0 (bank 1, enc 1)

04 · The four channels

05 · Gestures (as the daemon paints them)

The envelopes from mft/overlays.py and mft/render.py, run at 30 fps on the target encoder. The values sent are listed below.

t ring pos ring lvl ch6 rgb lvl ch3

06 · The ten states

Every session state on its own encoder — hue, animation and ring — as render.py paints it. Start the clock first, or the animated states will not be in phase.

enc state hue ch3 ring

09 · Usage milestones — the announcement

The five-hour usage window belongs to no session, so it gets no encoder: it gets the whole bank for a few seconds, once on the way past each milestone. This is mft/overlays.py's UsageOverlay at 30 fps over the target bank — the word USE, each letter held still and then cut to the next, and then the reading itself as rows filling from the bottom. The preview mirrors what goes on the wire, so the panel is worth something with no device attached.

The word no longer carries the number, so its three letters can afford to stand still: each is held for USAGE_LETTER_SECONDS (0.85) and cut, not faded, to the next — a fading letter is legible for half the time it is on screen. RATE beside it is the old envelope, strike-and-decay at the banner's pace, for the comparison.

Then the bar: one row per quarter of the window, filling from the bottom, flashed USAGE_GAUGE_FLASHES times. A reading between rows lights its leading row partially, which is the only thing that tells 90, 95 and 100 apart — they all reach the top row. Hue and brightness ramp together over the whole span, amber and dim at the bottom to full red at 100. Milestone → the word's hue: .

10 · Usage milestones — the rule

The other half, and the half that decides whether you ever see the word: mft/usage.py's watermark. Move the percentage and this plays exactly what the daemon would have played — which is usually nothing. Readings come from Claude Code's own cache (~/.claude.json), so they arrive minutes late and in jumps.

reading watermark crossed board

Three silences, all deliberate: the first reading of a daemon's life is adopted, not announced; a window that rolls over resets without a word; and a percentage that wobbles back across a milestone it already spelled says nothing. A jump spells only the highest milestone it passed — try 20% → 96%.

07 · Sweeps — what mft.calibrate does

Sixteen values at a time across bank 1. Set what you find in config.py or an MFT_* env var.

no sweep running

08 · Raw CC + monitor

Inbound messages below. A press is channel 2 (≥ 64 down, 0 up); a turn is channel 1, which the daemon ignores by design.


        

Encoders must be set to accept host LED control in the Midi Fighter Utility. Otherwise the device drives its own LEDs and ignores this page entirely, which looks the same as a wrong value table.