Skip to content
Radu Mailat edited this page Oct 28, 2026 · 4 revisions

A guided tour of the FM1-GM firmware. Start with the overview below, then work through the lessons in order.

Lessons

  1. Stage 1: The skeleton
  2. Stage 2: The song's data model
  3. Stage 3: Playback and recording
  4. Stage 4: The simulator and tests
  5. Stage 5: The UI
  6. Stage 6: Power-safe storage
  7. Stage 7: The sound engine
  8. Lesson 8: The USB stack
  9. Lesson 9: The firmware installer
  10. Lesson 10: The web pages

An architectural tour of jartificer/fm1-gm at commit fcc7655 (REV 152).

1. What it is

FM1-GM is replacement firmware for the M-VAVE FM-1, a small keyboard synth. It turns it into two things at once:

  • A General MIDI sound module: 128 GM instruments plus a drum kit, played from the keys, USB MIDI or the TRS MIDI jack.
  • A 16-track loop sequencer: record, step-record, edit bars, save up to 64 songs in flash.

The hardware is tight: a JieLi AC79 microcontroller with a single-precision FPU, 1 MiB of flash (about 580 KB for the whole app image, of which roughly 420 KB is the sound samples), a 240×240 colour LCD, a key/encoder/LED matrix, and 44.1 kHz stereo audio out. Almost every design choice in the code follows from those limits.

Around the firmware sit a host simulator, a WebAssembly build for a browser emulator, a web song editor, and Python tools that build the sound bank, the fonts and the install package.

2. The repository map

Folder What lives there Your relationship to it
firmware/hal/ Hardware access layer (registers, timers, audio DMA, flash, USB), header-only. Inherited from the Felucca project, unchanged. Read for understanding, not for learning style.
firmware/loader/, crt0.S, app.ld Startup assembly, linker script, update loader. Also Felucca's. Skim once.
firmware/src/main.c The unity build: #includes every .c that touches the HAL into one compilation unit. Read early; it's the table of contents.
firmware/src/app/ The application: boot and main loop (main_fm1.c), UI (ui.c, the biggest file), settings, song slots, platform layer (plat.h, plat_fm1.c), SysEx link for the web editor (link.c). Core study material.
firmware/src/dsp/ The sound engine (gm.c) and the sequencer (seq.c). Pure C, no hardware. Core study material.
firmware/src/storage.c Power-safe flash storage for settings and songs. Excellent self-contained lesson.
firmware/src/usb.c, midi_uart.c, ota.c USB MIDI/audio device, serial MIDI, firmware update over USB. Later, if curious.
host/ gm_host.c runs the whole app on a PC from a script; gm_web.c is the same for WebAssembly. Key to understanding testability.
tests/ C unit tests, scenario scripts (tests/scenarios/*.gm), JS tests. Run with tests/run_tests.sh. Use as executable documentation.
tools/ Python: build.py (device build), sf2_reduce.py and gen_wavetable.py (sound bank), gen_font.py, installers. Read when studying the build.
web/ Static site: emulator, installer, song editor. Separate JS track.

The code you would actually write "of this scope" is mostly in firmware/src/app/, firmware/src/dsp/, storage.c and host/, about 6,000 lines of C.

3. Entry points

There are three ways the same app code starts, and seeing that is the first big lesson.

On the device. The chip's bootloader jumps into crt0.S, which calls fm1_cstart() in firmware/src/app/main_fm1.c. That function arms the watchdog, counts failed boots (a "boot guard" kept in RAM that survives reset), clears .bss, copies .data, then calls fm1_main(). fm1_main() initialises the LCD, input, the sequencer (seq_init, which also starts the engine), loads settings and the last song, starts audio, USB and the input timer, then runs the main loop forever.

On a PC. host/gm_host.c defines OM_HOST, provides its own versions of every plat_* function (a fake framebuffer, fake buttons, a WAV writer, a RAM "flash"), includes the same app/ and dsp/ sources, and runs a script such as tests/scenarios/move.gm (tap HOME, key 7 down, expect evnote0 62, shot move_on.png).

In the browser. host/gm_web.c does the same for WebAssembly, driven by web/emu.js.

The trick that makes this possible is firmware/src/app/plat.h: a small C interface (plat_buttons(), plat_keys(), plat_enc(), plat_midi_in(), plat_song_save() and so on) that the app uses instead of touching hardware. The device implements it in plat_fm1.c; the host implements it in gm_host.c. This is the ports-and-adapters idea, done in plain C with no function pointers: the linker simply picks whichever implementation was compiled in.

4. The three execution contexts

This is the most important thing to understand before reading any file. Code runs in three contexts that interrupt each other:

  1. Audio interrupt (fm1_alnk0_irq → render_block → seq_render, priority 3). Fires every 5.8 ms when the audio DMA has played half its buffer. It must fill the next 256 stereo frames before the DMA gets there, or you hear a click. It runs the sequencer and the engine.
  2. Input timer interrupt (fm1_timer5_irq, 10 kHz, priority 4). Scans one column of the key/encoder/LED matrix per tick, polls USB and the TRS jack, and keeps the millisecond clock. It is set above audio so it can interrupt a render and keep the matrix scan steady; the comment explains that lower priority made LEDs flicker and encoder turns get lost.
  3. Main loop (fm1_main's for(;;)). Feeds the watchdog, reads the volume knob and battery, services the SysEx link and updates, and calls ui_frame() about 60 times a second (ui_input_only() in between).

The rule that keeps this safe: the audio interrupt owns the song and the engine. The main loop never changes them directly. It sends commands.

5. Data flow: a key press becomes sound

Follow one note through the system:

  1. The timer ISR scans the matrix and updates fm1_in.keys.
  2. The main loop calls ui_frame() → input() in ui.c, which reads plat_keys(), sees a new key, and calls key_down().
  3. key_down() works out the MIDI note and calls post(SC_NOTE_ON, track, note, velocity), which calls seq_post().
  4. seq_post() (seq.c) writes a 8-byte cmd_t into a 256-entry ring buffer and increments the write index. A compiler barrier (__asm__ volatile("" ::: "memory")) makes sure the slot is written before the index moves.
  5. The next audio interrupt calls seq_render(), which drains the ring (exec() for each command). exec for SC_NOTE_ON plays the note through gm_midi(0x90 | chan, note, vel) and, if recording, inserts it into the song.
  6. seq_render() then, in chunks of 32 frames, advances the sequencer clock (play_tick() whenever enough samples have passed for one tick at the current tempo), calls gm_render() to mix every active voice, adds the metronome click, applies the master gain through a soft clipper (fm_tanhf) and writes 24-bit integers to the DMA buffer.
  7. The DMA plays the buffer out of the codec.

MIDI in follows the same path: USB packets land in a queue in the timer ISR, midi_in() in ui.c reads them in the main loop and posts SC_NOTE_ON or SC_MIDI commands.

Playback follows the bottom half only: play_tick() walks each track's notes in tick order with a per-track cursor (pos[]), sends note-ons to the engine, and remembers each sounding note's end tick in the act[] table so it can send the note-off later.

The screen goes the other way: the audio side publishes read-only state (seq_tick, seq_playing, gm_ch_level[], seq_changes) as volatile globals, and ui.c reads them to draw.

6. Key design choices

Single-producer, single-consumer command ring instead of locks. The main loop only writes cq_w; the audio ISR only writes cq_r. Free-running 32-bit indices with % NCMD mean no wrap logic and no mutex. This is the standard way to talk to a real-time thread, and worth learning cold.

seq_lock for big operations. Loading or saving a song can't be squeezed into a command, so the main loop sets seq_lock; while it is set the render skips commands and playback and the main loop may touch seq_song directly.

Fixed-size, statically allocated everything. No malloc. A song is one struct with a flat ev[8192] array, sorted by track then tick, with each track's note count in trk[t].nev. Insertion is a memmove (ev_insert). Simple, predictable, and fast enough at this size. Big buffers go into a dedicated RAM section via OM_POOL / GM_POOL.

Compact song format, separate from the RAM layout. In RAM a note is 6 bytes (seq_ev_t). In flash, song_stream() writes MIDI-style variable-length numbers: tick deltas, the note, and the length only if it changed (bit 7 of the note flags "same length as before", like MIDI running status). About 2–4 bytes a note. song_parse() reads it back and validates every field, rejecting the whole song on any bad byte. song_stream() can also write just a window (from, n) or count bytes without writing (dst = 0), so the same function sizes, pages and writes a song.

Power-safe storage by write ordering. storage.c never overwrites a good copy. Settings use A/B sectors: write the payload, then the header last; the header is the commit record, and on boot the valid copy with the highest sequence number wins. Songs go into free sectors as a new version, so a power cut mid-save leaves the old one intact. CRC-32 checks everything.

Unity build with two optimisation levels. main.c includes everything that touches the HAL into one file compiled -Os (small), while dsp/gm.c and dsp/seq.c are separate units at -O2 (fast), because they run in the audio interrupt.

Floats, but single precision only, no libm. The FPU is single precision; fastmath.h provides approximations (fm_exp2f, fm_tanhf, fm_sin_turns). The build checks that no double-precision soft-float routine sneaks in, and the host build uses -ffp-contract=off so it computes bit-identical samples to the device.

Graceful degradation under load. render_block measures how long each render took. Above 70 % of budget the engine switches from 8-point sinc to linear interpolation (gm_load); if still late, gm_shed() drops the quietest voice. A safety net in the engine resets it if a NaN or absurd level ever appears.

Defensive boot. The watchdog, the boot-loop counter (two failed boots → a safe mode with no audio but USB on, four → the chip's own update mode), a crash screen, and memory guards. This is what makes custom firmware safe to install on someone's device.

Tests that drive the real app. Because of plat.h, the scenario scripts press real buttons in the real UI code and check state, screenshots and audio. That's a big part of how one person can keep 16 views of UI behaviour correct.

7. Things that may confuse you while reading

  • Stale header comment in gm.h. It describes 2-operator FM synthesis and synthesized drums. The engine is now a sample player (gm.c's own header comment is correct). Trust gm.c.
  • plat.h says plat_midi_in is called from the audio ISR. It's actually called from midi_in() in ui.c, in the main loop. Comments drift; check the call site.
  • The comment style is terse and sentence-fragment-heavy ("the slot before the index", "0, or -1 (full)"). Most functions document their return values in a trailing comment on the declaration in the header. Read headers first.
  • Names come from three ancestor projects (Felucca, X0X, FoMni). OM_ prefixes, "OMNI" in comments, and FELUCCA_ flags are inherited, not meaningful.

8. A learning path

Each stage builds on the last and ends with something you could write yourself.

Stage 1. The skeleton (1–2 sessions). main.c, app/plat.h, app/app.h, then main_fm1.c (boot, the two ISRs, the main loop). Goal: draw the three contexts and what each one owns. Exercise: explain why TIMER5 has a higher priority than audio.

Stage 2. The sequencer's data model (1–2 sessions). dsp/seq.h in full, then in seq.c: the command ring, song_defaults, song_stream / song_parse. Goal: write a song encoder/decoder of your own on paper and compare. Exercise: hand-encode a two-note track.

Stage 3. Playback and recording (2 sessions). seq.c: play_tick, seek, act_add, seq_render, then ev_insert, rec_on / rec_off, quantize, undo (snapshot). Goal: be able to write a tick-based MIDI sequencer.

Stage 4. The host simulator and tests (1 session). host/gm_host.c, one scenario script, tests/run_tests.sh. Run them. Goal: understand how to make embedded code testable, and have a sandbox for every later stage.

Stage 5. The UI (2–3 sessions). ui.c: the ui state struct, input(), button(), the knob tables (knob_get / knob_set), midi_in(), then the drawing functions. Goal: see how a 240×240 screen with 6 views and paged knobs is organised as data plus a few dispatch functions.

Stage 6. Storage (1 session). storage.c with tests/storage_test.c. Goal: be able to write a torn-write-safe store from scratch.

Stage 7. The sound engine (2–3 sessions, the hardest). gm.c: channels and controllers (gm_midi), voice allocation and stealing, envelopes, ADPCM decode and sinc interpolation in voice_render, reverb, then fastmath.h. Optionally tools/gen_wavetable.py and sf2_reduce.py to see where the samples come from. Goal: be able to write a polyphonic sample player.

Optional tracks. USB MIDI and audio (usb.c), firmware updates (ota.c, tools/fm1_install.py), the SysEx link (link.c, web/fm1link.js), the web editor (web/), the build and package (tools/build.py).


Contents · Stage 1: The skeleton →

Clone this wiki locally