Skip to content

Stage 5 The UI

jartificer edited this page Oct 28, 2026 · 1 revision

How 14 buttons, 7 encoders, 27 keys and a 240×240 screen become a usable instrument. File: firmware/src/app/ui.c (1,700 lines, the biggest in the app), plus firmware/src/gfx.c for the canvas. Read the long comment at the top of ui.c first; it is the user manual in 30 lines.

By the end you should be able to structure a multi-screen embedded UI as data plus a few small dispatch functions, and draw it efficiently.

1. How the file is organised

ui.c is called from the main loop only, through three entry points (app.h):

  • ui_init() once at boot,
  • ui_frame() about 60 times a second: input, then drawing,
  • ui_input_only() in between frames: input only.
void ui_frame(void)
{
    input();        // buttons, keys, encoders, MIDI in
    bar_follow();   // the Bar view follows the step cursor
    pending();      // finish a load or save if the sequencer has stopped
    autosave();     // save settings after a quiet moment
    draw_header();  // the three bands of the screen
    draw_main();
    draw_knobs();
    leds();
    ui.frame++;
}

The file then falls into sections, marked by /* --- name --- */ banners: state and tables, knobs, notes (keys and MIDI in), buttons, screen, and a host-only fit check at the end.

A key fact from Stage 1 shapes everything: the UI never changes the song directly. It reads seq_song and the seq_* state freely, but every change goes through post() → seq_post() to the audio side.

2. State: one struct

All UI state lives in one static struct { ... } ui;. Read its fields; each has a short comment. Groups:

  • Where you are: view (which of 6 pages), kpage[] (which knob page per view), bar, slot_cur, oct.
  • Input bookkeeping: btn, keys (last state seen), btn_t[] (when each button went down), btn_used (already acted on), enc_t[] (last encoder step time).
  • Per-key memory: key_note[], key_trk[], key_pick[]: what each held key is playing.
  • Transient UI: msg, msg_until (a two-line message in the header), touched, touch_until (highlight the knob just turned), confirm, confirm_until (a press waiting for its confirmation).
  • Background jobs: pend, pend_slot, pend_t (a load or save waiting for the sequencer to stop), set_dirty (settings need saving), saved_changes.
  • Drawing: frame (counter for blinking), sig[3] (signatures of what's on screen, see section 8).

One struct means ui_init() can reset it all with one memset, and you can see the whole UI state in one place. Many fields have an _until or _t timestamp partner: a lot of UI behaviour is "this state, until this time".

3. Views and knobs: data, not code

There are six views (V_SONG, V_TRACK, V_MIX, V_SONGS, V_SETUP, V_BAR) and four knobs under the screen. What each knob does depends on the view, and two views have a second page. That mapping is a table:

static const uint8_t VIEW_KNOB[NVIEWS][NKPAGES][4] = {
    {{K_TEMPO, K_BARS, K_QUANT, K_METRO}, {K_TEMPO, K_BARS, K_QUANT, K_METRO}},   // Song
    {{K_FAMILY, K_PROG, K_VOL, K_PAN},    {K_CHAN, K_REV, K_TRANSPOSE, K_KEYVEL}}, // Track, 2 pages
    ...
    {{K_BAR, K_STEP, K_LEN, K_QUANT},     {K_BAR, K_STEP, K_LEN, K_QUANT}},        // Bar
};
static const uint8_t MOVE_KNOB[4] = {K_BAR, K_STEP, K_PITCH, K_START};             // Bar, MOVE mode

Each knob function (K_TEMPO, K_VOL, …) is an id in an enum. Then five functions, each a switch on the id, describe every knob:

Function Answers
knob_get(k) What's the current value?
knob_range(k, &lo, &hi) What range? (a small table, plus a few computed ranges)
knob_name(k) The label: "TEMPO"
knob_text(k, v, buf) How to show value v: "1/16", "L12", "C#4", "+3"
knob_set(k, v) Make it so (usually by posting a command)

With those, one generic function handles every turn of every knob in every view:

static void turn(int role, int k, int32_t e)
{
    int lo, hi;
    if (!e || k == K_NONE) return;
    if (k == K_PITCH || k == K_START) { move(k == K_START, e); return; }   // MOVE mode is special
    knob_range(k, &lo, &hi);
    knob_set(k, knob_get(k) + accel(role, e, hi - lo));
}

and one generic draw_knobs() draws all four: name, value text, and a bar showing where the value is in its range (centred for ranges that go negative, like pan).

This is the most reusable idea in the file. It's a property table: each property knows how to get, set, range-check and format itself, and the generic code never needs to know what a property means. To add a knob you add an enum value, a case in each of the five functions, and put it in the table. The display, the clamping and the acceleration come for free. Compare that to writing a handler per knob per view.

Two things knob_set does that are easy to miss:

  • It clamps to the range, then returns early if the value didn't change. So turning past the end of a range posts nothing and doesn't mark the song dirty.
  • Song and track knobs post commands (SC_SONG, SC_TRK). Settings knobs (MIDI in mode, theme, voices) write set directly, because settings belong to the main loop, then call settings_apply() to copy the relevant ones to seq_cfg and set ui.set_dirty for the autosave.

Acceleration

A tempo knob from 30 to 300 one detent at a time is tedious. accel() multiplies a step by up to 6 depending on how fast the knob turns, measured as milliseconds between detents:

m = dt < 15u ? 6u : dt < 30u ? 4u : dt < 60u ? 2u : 1u;

Only for ranges wider than 24, so small choices (on/off, the 7 quantize values) are never skipped.

4. Input: edges, holds and chords

input() runs at the start of every frame and in between. It compares the current buttons with the last ones it saw:

ch = btn ^ ui.btn;      // XOR: the bits that changed
ui.btn = btn;

ch & btn is a press, ch & ~btn a release. That's edge detection, and XOR is the way to do it.

The tricky part is that some buttons have both a short and a long action (ARP: undo, or held 1 s: erase the track). You can't act on the press, because you don't yet know which it is. So:

if (ch & m) {
    if (btn & m) {                                   // pressed
        ui.btn_used &= ~m;
        ui.btn_t[i] = now;
        if (i == B_PLAY || i == B_REC) button(i, 0); // transport acts on the press
    } else if (!(ui.btn_used & m) && i != B_PLAY && i != B_REC) {
        button(i, 0);                                // others act on release, if not used
    }
} else if ((btn & m) && !(ui.btn_used & m) && has_long(i) && now - ui.btn_t[i] >= LONG_MS) {
    ui.btn_used |= m;                                // held long enough: the long action
    button(i, 1);                                    // and mark it so release does nothing
}

Read it as a little state machine per button:

  • PLAY and REC act on the press, because timing matters for music: you want playback to start when your finger goes down.
  • Everything else acts on the release, unless the button was "used".
  • A hold of 1 s, on a button that has a long action in the current view (has_long()), fires the long action immediately (not on release, so you get feedback) and marks the button used, so the release does nothing.
  • A chord of OCT− and OCT+ (the 5 s update-mode hold) marks both as used, so letting go doesn't also change the octave.

button(b, held_long) is then one big switch deciding what each button does, depending on the view and mode. Read it with the user manual at the top of the file next to it.

Two-press confirmations

Destructive actions need a second press: loading over unsaved changes, saving over a used slot, deleting a song, starting a new song. They all use the same pattern:

if (song_dirty() && ui.confirm != C_LOAD) {
    ui.confirm = C_LOAD;
    ui.confirm_until = plat_ms() + 4000u;
    say("NOT SAVED", "[SEL] LOAD ANYWAY");
} else {
    ui.confirm = C_NONE;
    request(P_LOAD, ui.slot_cur);
}

First press: arm the confirmation for 4 s and tell the user what to do. Second press within 4 s: do it. input() clears confirm when it expires, and set_view() clears it when you change view. No modal dialog needed, and the user can't get stuck.

Messages

say(a, b) copies two short strings into ui.msg and sets msg_until to 1.4 s from now. The header draws them instead of the normal header until then. A cheap, non-blocking toast.

5. Keys and MIDI in

key_down(key) turns a key index into a note (KEY_BASE 53 = F3, plus 12 per octave; drum tracks start two octaves lower so the GM kick drums fall on the lowest B and C), and posts SC_NOTE_ON with the track's velocity.

Notice what it remembers:

ui.key_note[key] = ui.key_pick[key] = (uint8_t)n;
ui.key_trk[key] = seq_sel;

key_up() then sends the note-off to the track and note remembered at the press, not the current ones:

static void key_up(int key) { post(SC_NOTE_OFF, ui.key_trk[key], ui.key_note[key], 0, 0); }

If you press a key, change track, then release, the note-off still goes where the note-on went. Otherwise the note would hang forever. Same idea as Stage 3's "end what's sounding before remapping", applied to input. And for extra safety, changing track or octave calls keys_release() first, which sends note-offs for every held key.

midi_in() applies the same idea to incoming MIDI. In TRACK mode, every incoming note plays the selected track; ui.midi_trk[note] remembers which track each held note went to, so its note-off follows it. In MULTI mode, messages go straight to the engine (SC_MIDI), making the device a 16-channel GM module.

6. Background jobs: deferred load and save

Loading or saving needs the sequencer stopped and seq_lock set (Stage 2). But the UI can't stop the sequencer synchronously; it can only post SC_STOP and wait for the audio side to process it. So a load or save is a two-step job:

static void request(int what, int slot)   // step 1: ask
{
    post(SC_STOP, 0, 0, 0, 0);            // (plus leaving step and record mode)
    keys_release();
    ui.pend = what;  ui.pend_slot = slot;  ui.pend_t = plat_ms();
}

static void pending(void)                 // step 2: every frame, check if we can finish
{
    if (!ui.pend || seq_playing || !seq_idle()) {
        if (ui.pend && plat_ms() - ui.pend_t > 3000u) { ui.pend = P_NONE; say("BUSY", "NOT DONE"); }
        return;
    }
    seq_lock = 1;
    ... song_save_slot / song_delete_slot / song_load_slot ...
    seq_lock = 0;
    ...
}

seq_idle() is true when the command ring is empty, meaning the audio side has processed the stop. Only then does the UI take the lock and touch the song. And it gives up after 3 s with a message instead of waiting forever. Any wait in a UI needs a timeout and a message.

After a load, the UI resets its dirty tracking (saved_changes = seq_changes) and posts SC_GMRESET and SC_SELECT so the engine and the selected track match the new song.

Dirty tracking and autosave

song_dirty() is seq_changes != ui.saved_changes. The header shows S03* and the SAVE button blinks when it's true. The sequencer just counts changes; the UI remembers the count at the last save. To mark a song as "not saved anywhere" after deleting its slot, the UI sets saved_changes = seq_changes - 1. A neat trick: no boolean to keep in sync.

Settings save themselves. autosave() waits until settings are dirty, the sequencer is stopped, nothing is held, and 4 s have passed since the last activity. Why so careful? Because erasing flash silences audio briefly (Stage 1), and you don't want a glitch while someone is playing.

Optimistic updates

select_track() posts SC_SELECT and also sets seq_sel directly, so the screen shows the new track this frame instead of after the next audio block. That bends the "only the audio side writes" rule, safely here because both sides write the same value. It's the same trade web apps make with optimistic updates. Use it sparingly and only when both writers agree.

7. The Bar view in brief

The Bar view is the most complex screen: one bar of the selected track as a mini piano roll, with a step cursor. Ideas worth noticing in draw_bar(), len_get() and move():

  • The pitch range shown adapts to the notes in the bar and the keys held, at least an octave, so the notes always fill the view.
  • Notes held over from the previous bar or into the next are clipped to the bar and marked with a < or > chevron.
  • Holding keys narrows LENGTH, MOVE and delete to those notes. ui.key_pick[] tracks which pitch each held key "picks", and follows the note when MOVE changes its pitch.
  • move() asks seq_step_can_move() (a read-only function in seq.c) before posting. If the target is blocked by another note, it tries one further, and so on: "landing on a note jumps past it". The UI does the search; the sequencer just applies a move it's told is valid. Reading seq_song from the main loop is allowed; changing it isn't.

8. Drawing efficiently

The display is 240×240 pixels at 16 bits each: 115 KB per full screen, sent over SPI. Redrawing the whole thing 60 times a second would eat the CPU. The UI uses two techniques.

8.1 Bands and an off-screen canvas

The screen is split into three bands: the header (28 px), the main page (144 px), the knobs (68 px). Each band is drawn into an off-screen buffer, the canvas (cv_px in gfx.c, in .pool), then sent to the display in one lcd_blit:

cv_begin(240, MAIN_H, K_BG);   // clear the canvas to the background
... cv_rect, cv_round, cv_text ...
cv_blit(0, MAIN_Y);            // one transfer to the LCD

Drawing off-screen and then blitting avoids flicker (you never see a half-drawn frame) and makes one efficient transfer. That's double buffering again, for pixels this time.

8.2 Redraw only what changed: signatures

Each band computes a signature, a hash of everything it depends on, and redraws only if it changed:

static uint32_t hash(uint32_t h, uint32_t v) { return (h ^ v) * 16777619u; }   // an FNV-1a step

static void draw_main(void)
{
    uint32_t h = main_sig();
    if (h == ui.sig[1]) return;   // nothing it shows has changed
    ui.sig[1] = h;
    cv_begin(...); ... cv_blit(...);
}

main_sig() mixes in the view, the knob page, the theme, the selected track, seq_changes (any song edit), each track's level meter quantised to 16 steps, the play position quantised to 6 ticks, the step cursor, and view-specific values. Quantising matters: hashing the raw level would redraw every frame; 16 steps is what the meter can show.

This is the same idea as React's "render only if props changed", or memoisation, done by hand with a hash. The cost: if you forget to hash something the band displays, the screen goes stale when only that thing changes. Every time you add something to a draw function, add it to the signature. That's why changing the theme zeroes all three signatures: everything must redraw.

The LEDs, by contrast, are recomputed from scratch every frame in leds() ("immediate mode"). There are only 41 of them, so there's nothing to optimise. Blinking comes from the frame counter: (ui.frame / 8) & 1 toggles about every 130 ms.

8.3 Themes

Colours come from a theme_t struct with a field per meaning (acc for values, play, rec, drum, selc for the selection) rather than per colour. Draw code uses macros like K_ACC that read the current theme. Five themes, including two for colour-blind players with accents from the Okabe–Ito palette. Naming colours by role is what makes themes possible at all.

8.4 Will it fit?

On a 240 px screen, a long program name or message can overflow. ui_fit_check(), compiled only on the host, measures every knob name, every possible knob value, every help line and the longest messages against the space they get, and the expect fit 0 test fails if any don't fit. A UI test that runs over all values instead of a few samples.

9. What to take away

  • Keep UI state in one struct; pair transient states with an expiry time.
  • Describe controls with a property table (get, range, name, format, set) and write the generic code once.
  • Detect edges with XOR. Act on press for timing-critical buttons, on release for buttons with a long action. Mark a button used after its long action.
  • Remember at press time where a note went, so the release follows it.
  • Confirm destructive actions with a timed second press.
  • Defer jobs that need another context to finish first: request, poll, timeout.
  • Track dirtiness with a change counter, not a flag.
  • Draw bands into an off-screen canvas, and redraw a band only when the hash of what it shows changes. Quantise what you hash.
  • Name colours by meaning.
  • Test that text fits, for every value.

10. Exercises

  1. Add a hypothetical knob K_SWING (0–50 %) to the Song view's second page. List every place in ui.c you'd touch. (Assume a SP_SWING song parameter exists.)
  2. In input(), what happens if you press and hold ENV in the Song view for 2 seconds? And in the Bar view? Trace it through has_long() and button().
  3. draw_header()'s signature hashes seq_tick / SEQ_PPQN. What does the header show that needs that, and why not hash seq_tick directly?
  4. Find a bug class the signature approach invites, and write a scenario (Stage 4) that would catch one instance of it.
  5. Why does key_up not call key_to_note(key) to find the note to stop? Describe the user action that would break if it did.
  6. Sketch, on paper, a property table for a different device: a thermostat with target temperature, mode (heat/cool/off), fan speed and schedule on/off. Write get, range, name, text and set for it.

Next stage: storage, firmware/src/storage.c.


← Stage 4: The simulator and tests · Contents · Stage 6: Power-safe storage →

Clone this wiki locally