Repository navigation
Home
A guided tour of the FM1-TRAX firmware. Start with the overview below, then work through the lessons in order.
Lessons
- Stage 1: The skeleton
- Stage 2: The song's data model
- Stage 3: The sound driver
- Stage 4: The YM2612
- Stage 5: The PSG and the mixer
- Stage 6: The simulator and tests
- Stage 7: The UI
- Stage 8: The chunk store
- Lesson 9: VGM export and the SysEx link
- Lesson 10: The web pages
- Lesson 11: What comes from FM1-GM
An architectural tour of jartificer/fm1-trax at commit bea3146 (1 November 2026).
FM1-TRAX is replacement firmware for the M-VAVE FM-1, a small keyboard synth. It turns it into a Sega Mega Drive / Genesis music tracker:
- Two emulated sound chips. The Yamaha YM2612 (six 4-operator FM channels, with the DAC on channel 6) and the TI SN76489 PSG (three square waves and a noise channel), emulated at register level.
- A pattern tracker in the style of Deflemask and Furnace: an order list, patterns per channel, cells with note, instrument, volume and two effects, Deflemask's Genesis effect set.
- Instruments: 128 FM presets, 32 PSG presets, a 48-sample drum kit, 256 user FM and 128 user PSG slots.
- 256 song slots in about 258 KB of flash, compressed with LZ4, saved power-safely.
- VGM export, so a song plays in any VGM player or on real hardware.
The hardware is the same as FM1-GM's: a JieLi AC79 microcontroller with a single-precision FPU, 1 MiB of flash (an app slot of about 580 KB), a 240×240 colour LCD, a key/encoder/LED matrix and 44.1 kHz stereo audio. The difference is the workload. FM1-GM played samples; FM1-TRAX runs a chip emulator at the YM2612's own 53,267 Hz and resamples it. That is why so much of the code is about time: the time budget of the audio interrupt, and the timing of the tracker.
Around the firmware sit a host simulator, a WebAssembly build of the same app (the emulator page and the desktop editor), a VGM importer in JavaScript, and Python tools that build the fonts, the drum kit and the install package.
| Folder | What lives there | Your relationship to it |
|---|---|---|
firmware/hal/ |
Hardware access layer, header-only. Felucca's, unchanged. | Read for understanding, not for style. |
firmware/loader/, crt0.S, app.ld
|
Startup assembly, linker script, update loader. Felucca's. app.ld adds .ram_hot (Stage 1). |
Skim once. |
firmware/src/main.c |
The unity build: includes every .c that touches the HAL into one compilation unit. |
Read first; it's the table of contents. |
firmware/src/app/ |
Boot and main loop (main_fm1.c), the platform layer (plat.h, plat_fm1.c), settings and song slots (settings.c), the UI (ui.c, ui_pat.c, ui_views.c), the SysEx link (link.c). |
Core study material. |
firmware/src/trk/ |
The tracker: the song model (song.h, song.c, ins.h), the sound driver (player.c), presets, VGM export, the demo song. Pure C, no hardware. |
Core study material. |
firmware/src/dsp/ |
The chips: opn2.c (YM2612), psg.c (SN76489), mdsnd.c (the console: resampling, filter, DAC stream). Pure C, no hardware. |
Core study material, the hardest part. |
firmware/src/storage.c, lz4.c
|
The settings' A/B store and the new chunk store; LZ4 block compression. | Excellent self-contained lessons. |
firmware/src/usb.c, ota.c, midi_uart.c, lcd.c
|
USB MIDI and audio, firmware update, TRS MIDI, LCD driver. FM1-GM's, unchanged. | See Lesson 11 and the FM1-GM wiki. |
host/ |
drive_host.c runs the whole app on a PC from a script; drive_web.c is the same for WebAssembly. |
Key to testability. |
tests/ |
C unit tests (tests/host/), scenario scripts (tests/scenarios/*.fd), JS tests, all run by tests/run_tests.sh. |
Executable documentation. |
tools/ |
build.py (device build), gen_drums.py (the drum kit), gen_font.py, vgm2fm1d.mjs, installers. |
Read when studying the build. |
web/ |
Static site: emulator, installer, editor, VGM import. | Lesson 10. |
docs/DESIGN.md |
The design document: what every part is meant to do. | Read alongside every lesson. |
The code you would write "of this scope" is mostly in trk/, dsp/, app/, storage.c and host/: about 9,800 lines of C (300 of them preset data), plus 3,500 lines of JavaScript in web/.
The same app code starts in three ways.
On the device. The chip's bootloader jumps into crt0.S, which calls fm1_cstart() in firmware/src/app/main_fm1.c. That arms the watchdog, counts failed boots, clears .bss and .pool, copies .data and the RAM-resident code (.ram_text), then calls fm1_main(). fm1_main() initialises the LCD, input, the DAC kit (presets_bank_init), the player and the chips (player_init), loads the settings and the last song, starts audio, USB and the input timer, then runs the main loop forever.
On a PC. host/drive_host.c defines OM_HOST, implements every plat_* function itself (a framebuffer written to PNG, buttons from a script, a WAV writer, a 1 MiB array as the flash), includes the same app/ sources, links the same trk/ and dsp/ units, and runs a script such as tests/scenarios/edit.fd (tap REC, tapkey 0, expect note0.0 42, shot block.png).
In the browser. host/drive_web.c does the same as a freestanding WebAssembly module (no Emscripten, no libc), driven by web/emu.js for the emulator and by web/editor.js for the desktop editor.
The trick is firmware/src/app/plat.h: a small C interface (plat_buttons(), plat_keys(), plat_enc(), plat_midi_in(), plat_obj_open() and so on) that the app uses instead of hardware. The device implements it in plat_fm1.c, the host in drive_host.c, the browser in drive_web.c. The linker picks whichever implementation was compiled in. This is ports and adapters in plain C.
Code runs in three contexts that interrupt each other. This is the most important thing to understand before reading any file.
-
Audio interrupt (
fm1_alnk0_irq→render_block→player_render, priority 3). Fires every 5.8 ms when the audio DMA has played half of its buffer. It must make the next 256 stereo frames before the DMA gets there. It runs the tracker's driver and both chips. -
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 has a higher priority than audio so the scan keeps its rhythm during a render. -
Main loop (
fm1_main'sfor(;;)). Feeds the watchdog, reads the MASTER pot and the battery, services the SysEx link and updates, and callsui_frame()(input, and a redraw every 33 ms) orui_input_only().
The rule that keeps this safe: the audio interrupt owns the player and the chips. The main loop talks to them through a command ring (player_post). The song itself is shared with one exception to the rule, which Stage 3 explains: the main loop edits the song's cells in place, and takes player.lock for anything that moves memory around.
- The timer ISR scans the matrix and updates
fm1_in.notes. - The main loop calls
ui_frame()→input()inui.c, which comparesplat_keys()with the last value, sees a new key and callskey_down()andview_key(). - In the pattern view,
pat_key()picks the channel the instrument can play on (song_ins_ch) and callsjam_on(), which callsplayer_post(PC_JAM_ON, ch, note, ins). In edit mode it also writes the note into the cell under the cursor. -
player_post()(player.c) writes a 5-byte command into a 64-entry ring and moves the head index after a compiler barrier. - The next audio interrupt runs
player_render()→render()→player_run_commands(), which callsdo_cmd().PC_JAM_ONbuilds a one-off cell and callsapply_cell(), the same function the song's rows go through.note_on()loads the instrument's patch into the YM2612 by register writes (fm_load→md_fm→opn2_write) and marks the channel inkeyon_mask. -
render()renders one sample and then writes the key-on (register 0x28), so a note that follows another really restarts its envelopes. - Whenever enough samples have passed for a tick (
tick_left, Q16),tick()runs: new rows, effects, macros, andchan_tick()writes each channel's pitch (registers A0/A4) and volume (40) if they changed. - Between ticks,
md_render()(mdsnd.c) makes the YM2612's samples at 53,267 Hz in chunks (opn2_render), the PSG's at 44.1 kHz (psg_render), resamples the FM by linear interpolation, mixes, low-pass filters like a Model 1 console and removes DC. -
player_render()clips every sample to 16 bits and checks the safety net;render_block()scales by MASTER into the codec's 24 bits. The DMA plays it.
Playback is the bottom half of the same path: on the first tick of a row, tick() reads each channel's cell with song_cell_ro(&song, ch, song.order[ord][ch], row) and calls apply_cell(). Every other tick runs only chan_tick(), where slides, vibrato, arpeggios and macros move. advance_row() handles speed, swing, 0Bxx jumps, 0Dxx breaks and the loop.
The audio side publishes what the screen needs: player.ord, player.row, player.playing, each channel's level, and the chips' scopes (player.md.scope). ui.c reads them and draws. The stage view's "register heat map" comes from a hook: every register write calls player.md.log, which the UI points at stage_log(). The same hook writes the VGM export (Lesson 9).
Drive the chips only through register writes. The tracker never touches the emulators' internals. It writes registers through md_fm() and md_psg(), exactly as a Mega Drive sound driver does. Three things fall out of this for free: the sound is what real hardware would make, VGM export is a log of those writes, and the chip code can be tested in isolation against a reference emulator.
Sample-accurate, block-rendered chip emulation. opn2.c is a C port of ymfm's YM2612 model (the chip's own log-sine and exponent ROMs, envelope rate tables, detune, LFO, SSG-EG). It is checked against Nuked-OPN2, a die-level emulation. For speed, opn2_render() runs one operator through a whole block of samples at a time, falling back to sample-by-sample when something requires it, and a golden test proves the two give identical samples.
Fixed-point and integer in the hot path. The FPU is single precision and slow enough to matter; the chips are integer code. Floats appear only where a note's frequency is worked out, once per change.
Hot code in RAM. Code in flash runs through a cache, and a cache miss costs more than the work. The chips' inner loops and their tables carry DSP_HOT / DSP_HOT_DATA and are copied to RAM at boot. Calls from flash go through a function pointer (md_render_far).
Command ring instead of locks. The main loop writes the head, the audio ISR writes the tail. Free-running 32-bit indices, % RING, a compiler barrier between the slot and the index. The same single-producer single-consumer ring as FM1-GM, and worth knowing by heart.
Register caches. The driver remembers the last value written to each pitch, attenuation and total level (last_freq, last_att, last_tl) and skips writes that change nothing. Fewer writes means less work in the chips and smaller VGM files.
A song is a pool of rows. song_t is one big struct (about 173 KB) with an order list, a table of pattern starts and a pool of 16,384 rows. A pattern is allocated from the pool the first time something is written into it. No malloc, no fragmentation that can't be fixed by song_compact().
Separate the RAM layout from the byte format. In RAM a cell is 7 bytes; in the .fm1d stream a row is a bitmask of the fields present, and runs of empty rows are one byte. Then the stream is cut into 4 KiB blocks and LZ4-compressed. A 2-minute song takes 4.6 KB.
A chunk store for flash. Songs of any size, written as new versions in 256-byte chunks, each self-describing. The newest complete version wins; a save cut by power leaves the old one. Sectors are reclaimed by a garbage collector that spreads the wear.
A safety net on the sound. Every sample is clipped to 16 bits; a level that no real mix can reach means the engine's state is broken, and the block is dropped and the chips reset. A fuzzer drives random songs through the whole path to prove the net holds.
Tests that drive the real app. Because of plat.h, scenario scripts press real buttons in the real UI, check state, save screenshots and audio, and run again under AddressSanitizer. The browser build runs the very same code.
-
The old name. FM1-TRAX was called FM1-DRIVE until October 2026. You'll see it in
drive_host.c,drive_web.c, the.fdscenario files, the settings magic"FDRV", the song container magicsFDS1andFDL1, the SysEx ID bytes46 44("FD"), and the emulator's IndexedDB name. -
Effects are stored plus one. A cell's
fxbyte is the Deflemask command + 1, so 0 can mean "no effect".FX_ARPis 1 and means00xy;FX_PANis 9 and means08xy. The screen subtracts 1 when it prints. -
Operator numbering.
fm_patch_t.op[0..3]is OP1–OP4 in algorithm order. The registers interleave them as OP1, OP3, OP2, OP4 (offsets 0, 8, 4, 12 from 0x30), which is why bothplayer.candopn2.chave anOPREGtable. - Pitch is in 64ths of a semitone above C-0, so an octave is 768. Note numbers are 1..96 with 0 meaning "none".
-
Comments drift.
plat.hsaysplat_midi_inis "called from the audio ISR"; it is called bymidi_poll()inui.c, in the main loop. The header ofui.csays the screen is drawn "~30 a second" andmain_fm1.csays "~60 frames/s at most": both are right, because the main loop callsui_frame()every 16 ms butui_frame()only redraws every 33 ms. - The comment style is terse. Many functions document their return values in a trailing comment on the declaration in the header. Read headers first.
-
Names from ancestors.
OM_build flags (FoMni),FELUCCA_flags,X0Xcomments, the"X0X0"storage magic: inherited, not meaningful.
Each stage builds on the last and ends with something you could write yourself. Keep docs/DESIGN.md open.
Stage 1. The skeleton (1–2 sessions). main.c, app/plat.h, app/app.h, app/main_fm1.c, dsp/dsp_hot.h. Goal: draw the three contexts, what each owns, and how the audio block is measured and protected.
Stage 2. The song's data model (2 sessions). trk/song.h, trk/ins.h, trk/song.c. Goal: be able to design a tracker's data model and its byte format, and write the reader that never trusts it.
Stage 3. The sound driver (2–3 sessions). trk/player.h, trk/player.c. Goal: be able to write a tick-based tracker player with effects and macros that drives a chip by registers.
Stage 4. The YM2612 (3 sessions, the hardest). dsp/opn2.h, dsp/opn2.c. Goal: understand FM synthesis as the chip does it, in integers and logarithms, and how to make an emulator fast without changing a sample.
Stage 5. The PSG and the mixer (1–2 sessions). dsp/psg.c, dsp/mdsnd.c. Goal: band-limited square waves by box filtering, resampling between two clocks, one-pole filters and DC blockers in fixed point.
Stage 6. The simulator and tests (1–2 sessions). host/drive_host.c, a scenario, tests/run_tests.sh, tests/host/engine_test.c, golden_test.c, fuzz_test.c. Run them. Goal: a sandbox for every later stage, and a feel for testing against a reference.
Stage 7. The UI (2–3 sessions). app/ui.c, app/ui_pat.c, the shared input at the end of app/ui_views.c. Goal: see how a 240×240 screen, 14 buttons and 7 encoders become a tracker, drawn in bands with no frame buffer.
Stage 8. The chunk store (2 sessions). storage.c (the chunk store half), lz4.c, app/settings.c. Goal: be able to write a log-structured, power-safe flash store with garbage collection.
Lesson 9. VGM export and the SysEx link (1 session). trk/vgm.c, app/link.c.
Lesson 10. The web pages (1–2 sessions). host/drive_web.c, host/build_web.sh, web/emu.js, web/editor.js, web/vgmimport.js.
Lesson 11. What comes from FM1-GM. USB, the installer, the update loader: where to read about them, and what changed.
- Stage 1: The skeleton
- Stage 2: The song's data model
- Stage 3: The sound driver
- Stage 4: The YM2612
- Stage 5: The PSG and the mixer
- Stage 6: The simulator and tests
- Stage 7: The UI
- Stage 8: The chunk store
- Lesson 9: VGM export and the SysEx link
- Lesson 10: The web pages
- Lesson 11: What comes from FM1-GM