Skip to content

Lesson 10 The web pages

Radu Mailat edited this page Nov 3, 2026 · 1 revision

Lesson 10: The web pages

Files: host/drive_web.c, host/build_web.sh, web/emu.js, web/worklet.js, web/app.js, web/index.html, web/editor.js, web/editor.html, web/vgmimport.js, tools/vgm2fm1d.mjs, and the tests tests/web_editor_test.mjs, tests/web_fuzz_test.mjs.

FM1-TRAX has two web pages, and both run the firmware's own C code, compiled to WebAssembly:

  • index.html + app.js: an emulator of the whole FM-1 (screen, buttons, encoders, keys, sound, flash), plus the installer for a real device.
  • editor.html + editor.js: a desktop tracker that edits a song with a mouse and keyboard, plays it, imports and exports files, and syncs with a device over the SysEx link (Lesson 9).

One module, web/fm1trax.wasm, serves both. FM1-GM had the same split (see its Lesson 10); this lesson focuses on what is specific to the tracker.

By the end you should be able to build a C program into WebAssembly without Emscripten, explain how the emulator keeps sound and device time in step, and explain how the editor edits a C struct from JavaScript.

1. Building WebAssembly with plain clang

host/build_web.sh is 26 lines. It compiles each unit with

clang --target=wasm32 -O2 -ffp-contract=off -std=c99 -ffreestanding -nostdlib -DOM_HOST ...

and links with -Wl,--no-entry -Wl,--export-dynamic -Wl,--initial-memory=16777216 -Wl,-z,stack-size=262144. There is no Emscripten, no libc and no JavaScript glue generated for you. That is possible because the firmware already lives without a libc (Stage 1): it needs only memset, memcpy, memmove and memcmp, and drive_web.c defines those four at the top in a dozen lines.

Points to notice:

  • -ffp-contract=off matches the firmware and the host build. It stops the compiler from fusing a*b+c into one rounding step, which would make float results differ between builds. The few places that use floats (the mixer's filters, Stage 5) then give the same bits everywhere, so the golden hashes of Stage 6 hold in the browser too.
  • Exports: #define WEB(name) __attribute__((export_name(#name))). Every function marked WEB(...) becomes a function JavaScript can call as instance.exports.web_xxx. Pointers come back as plain numbers, offsets into instance.exports.memory.
  • --initial-memory=16777216: 16 MiB, fixed. The C side uses static arrays only (the 1 MiB flash, the 1 MiB io buffer, the audio queue), so nothing ever needs to grow.
  • The units are the same list as the host build: drive_web.c plus opn2.c, psg.c, mdsnd.c, song.c, player.c, presets.c, presets_fm.c, vgm.c. The committed web/fm1trax.wasm is about 547 KB.

The page loads it with nothing but the standard API:

const bytes = await (await fetch(url)).arrayBuffer();
const {instance} = await WebAssembly.instantiate(bytes, {});
this.x = instance.exports;

The import object is empty. The module asks nothing of JavaScript; JavaScript drives it.

2. drive_web.c: a fake FM-1

drive_web.c does for the browser what drive_host.c does for the simulator (Stage 6): it is the platform layer. It defines the lcd_*, plat_* and flash functions that the real firmware gets from its HAL, then #includes the same app sources the device's unity build includes: gfx.c, lz4.c, storage.c, plat_store.c, settings.c, ui.c, link.c. Nothing in those files knows it is in a browser.

Each piece of hardware becomes a static variable the page reads or writes through an export:

Hardware In drive_web.c Exports
240×240 LCD uint16_t fb[240*240], RGB565 byte-swapped as the panel takes it; fb_gen bumped on each draw web_fb, web_fb_gen
Buttons, keys held_btn, held_keys web_set_buttons, web_set_keys
Encoders enc_acc[NE], read and cleared by plat_enc web_turn
LEDs lit_btn, lit_keys, glow_on, play_red web_lit_*, web_glow, web_play_red
USB-MIDI two 256-packet rings web_midi_in, web_midi_out
SysEx sx_in[640] (one message at a time, as usb.c), sx_out[16384] web_sx_in_buf, web_sx_in, web_sx_busy, web_sx_out*
NOR flash uint8_t nor[1 MiB]: erase sets 0xFF, programming ANDs bits in, as real NOR web_nor, web_nor_size, web_store_writes

The flash model is worth a second look: st_prog does nor[off + i] &= p[i]. A real NOR flash can only clear bits; writing to a byte that is not erased gives the AND of old and new. Modelling that exactly means a storage bug that relies on overwriting would show up here too.

Time is simulated. web_run(ms) advances now_ms one millisecond at a time. Each step renders as many 256-frame audio blocks as are due (audio_due_x256 counts in 1/256 ms, so 256 frames at 44.1 kHz = 5.805 ms adds up without drift), runs link_service(), and runs ui_frame() every 16 ms (else ui_input_only()). That is the device's main loop and its audio interrupt, made sequential. Rendered audio goes into a 16,384-frame float queue (aq); if nobody takes it, the oldest is dropped.

3. emu.js: the audio clock drives everything

A browser gives you two clocks: the wall clock (setInterval, performance.now()) and the sound card. They drift. If the device ran on the wall clock and the audio was fed whatever it produced, you would hear clicks (queue empty) or growing latency (queue full).

emu.js makes the sound card the master clock:

  • worklet.js is an AudioWorkletProcessor. Its process() copies queued blocks to the output, 128 frames at a time, and every 4th call posts how many frames it has played so far.
  • On that message, audioTick() computes need = AHEAD - (sent - played), with AHEAD = 3072 frames (about 70 ms). It runs the device for need / 44.1 milliseconds, takes the rendered frames with web_audio_take, and posts them to the worklet (transferring the buffer, not copying it).

So the device runs exactly as fast as the speaker plays, and there are always about 70 ms queued. Before the first click there is no AudioContext (browsers forbid sound before a user gesture), so clockTick() on a 10 ms interval runs the device by wall time instead and discards the audio.

Other details in emu.js:

  • Taps: a press shorter than TAP = 40 ms of device time is held until 40 ms have passed (ups). A mouse click can be faster than one UI frame, and the firmware would otherwise never see it.
  • Screen: paint(img) returns early when web_fb_gen has not changed, else converts RGB565 to RGBA. (Note the byte swap back: ((v >> 8) | (v << 8)) & 0xFFFF.)
  • Flash persistence: when web_store_writes changes, a 400 ms debounce saves a copy of nor to IndexedDB (database "fm1drive-emulator", an old name kept so saved songs survive). On load, a saved flash of the right size is copied back before web_boot(0).
  • SysEx: sysex(msg) queues a message; sxPump() hands it to the module only when web_sx_busy() is 0. If the tab has no audio running, it calls run(2) so the editor still gets an answer.

app.js wires all of this to the panel drawn in index.html and adds bridge(), which connects the emulator to a BroadcastChannel("fm1trax-link") so the editor in another tab can treat it as a device.

4. The editor: editing a C struct from JavaScript

The editor calls web_ed_init() instead of web_boot(): no UI, no flash, just song_init, the presets and the player. Then it edits the song in the module's memory directly. There is no JavaScript copy of the song to keep in sync.

To do that it needs to know where each field of song_t is. Hard-coding offsets would break the moment someone reorders the struct. Instead the C side reports them:

WEB(web_layout) uint32_t web_layout(int k)
{
    static const uint32_t L[] = {
        offsetof(song_t, title), offsetof(song_t, author), offsetof(song_t, region), ...
        sizeof(cell_t), MACRO_LEN, sizeof(song_t),
    };
    ...
}

and editor.js builds an accessor object S on top:

const S = {
    get base() { return x.web_song(); },
    u8(k, i = 0) { return mem()[this.base + L[k] + i]; },
    get rows() { return this.u8(8); },
    order(o, ch) { return mem()[this.base + L[13] + o * L[23] + ch]; },
    ...
};

L[k] is just web_layout(k) read once at start. This is a small, effective technique for any C-to-JS bridge: let the compiler tell you the layout.

Cells are different. Patterns live in the shared pool (Stage 2), so a cell's address depends on allocation. The editor asks web_cell(ch, pat, row, alloc), which returns a pointer (or 0 for an empty pattern) using the firmware's own song_cell / song_cell_w. A cell is then 7 bytes at that pointer: note, ins, vol, fx0, fx1, fxv0, fxv1. Rules the device follows are asked for, not copied: web_ins_ch (which channel a jammed instrument plays on), web_is_carrier, web_fx_name, web_note_name, web_pool_free.

Undo is the simplest thing that works: before every edit change() pushes snapshot(), which is web_song_write() into io and a copy of those bytes. Undo restores with web_song_read. A song is a few KB as .fm1d, so 200 snapshots is cheap, and the undo cannot get out of step with the struct because it uses the real serialiser. The same bytes, base64-encoded in localStorage, are the autosave.

Playback uses the same worklet as the emulator, but pump() calls web_render(frames), which renders blocks on demand with no device loop. The transport posts the player's own commands (web_post(PC.PLAY, ...)), so the editor plays exactly what the device plays.

Drawing is a <canvas> repainted from requestAnimationFrame: drawPattern() reads cells straight out of module memory, web_pos() for the play position, web_ch_level and web_scope for the channel headers.

Files: Save writes .fm1d (web_song_write); .fm1i instruments use web_ins_write/web_ins_read; library banks are "FDL1" plus 16 instruments (web_bank_*). Export VGM is Lesson 9's vgm_export (web_vgm(900)). Export WAV sets web_stop_at_end(1), plays, and collects web_render output until the player stops.

5. VGM import: analysis by synthesis

vgmimport.js (about 970 lines) is the one large piece written only in JavaScript. It turns a VGM register log back into tracker rows, which is much harder than the export, because many different songs produce the same log and a log made by a game's sound driver was never on a tracker's grid at all.

The approach, from its header comment:

  1. Read the VGM (readVgm): header, commands, data blocks, GD3 tag.
  2. Find the tick (measureTick): collect the times of key-ons and PSG volume writes, then search periods near 44100/rate for the one where those times line up best. It treats each event as an angle 2πt/T and measures how well the angles agree (the length of the average unit vector). This is a discrete Fourier test at one frequency, and it finds both the tick length and its phase.
  3. Replay (replay): run the register writes and keep the chips' state at every tick: which FM channels keyed on, their pitch (converted to 64ths of a semitone with 768 * log2(f / C0)), PSG periods and volumes, DAC sample starts.
  4. Find the row grid (findGrid): try every pair of speeds s0, s1 (1..31 ticks, two values for swing) and every offset, and pick the grid that puts at least 98% of note starts on rows.
  5. Choose the cells by analysis through synthesis. The importer contains a model of FM1-TRAX's driver (simulate, tick for tick as player.c does it). For each row it tries candidate cells (candidates: plain note, vibrato, slides, tone portamento) and keeps the one whose simulated pitch is closest to the log (pitchCost). The result plays the log's notes because it was chosen by playing it.
  6. Lay out patterns (layout): cut the rows into patterns and share repeated ones. It uses rolling hashes of row spans so that "is this 64-row block equal to an earlier one" is O(1).
  7. Write the .fm1d stream (writeSong). The editor then loads it through the real song_read, so if the converter ever produced something the firmware would not take, the import is refused rather than loaded half-broken.

Drums are matched to FM1-TRAX's own kit by features of the sample (drumFeatures, mapDrums), since the tracker plays its kit, not the VGM's samples.

The same module runs from the command line: tools/vgm2fm1d.mjs turns assets/songs/SunsetBeach.vgm into the built-in demo song.

6. How it is tested

tests/run_tests.sh rebuilds the wasm when clang has the wasm32 target, then runs, under Node:

  • web_editor_test.mjs: loads fm1trax.wasm in Node, edits a song through web_layout, plays it, exports a VGM, and imports the demo song's own VGM back, checking the title, the loop, and that the worst pitch match is above 90%. Then it imports the source VGM the demo was made from. A round trip like export→import is a strong test for two halves of a converter.
  • web_fuzz_test.mjs: damaged and random VGMs must be refused with a message or import into a song the engine reads back.
  • web_install_test.mjs: the installer (inherited from FM1-GM).

Node can run the module because it needs nothing from the browser: no imports, no DOM. Keeping the C side free of the platform is what made that possible.

7. What to take away

  • A freestanding C program can be WebAssembly with plain clang. Provide the four mem* functions, mark exports, use static memory.
  • Put the platform behind a small interface (Stage 1) and a browser becomes one more platform: drive_web.c is a 600-line adapter, and every line of firmware above it is unchanged.
  • When producing real-time audio in a browser, let the audio device's consumption drive the simulation. Never the wall clock.
  • Let C report its own struct layout (offsetof) to the other language.
  • Use the real serialiser for undo, autosave and validation. One code path, no drift.
  • An inverse problem (log → song) can often be solved by search with a forward model: guess, simulate, compare, keep the best.

8. Exercises

  1. Run sh host/build_web.sh (or read it) and list what --no-entry and --export-dynamic each do. What would break without -ffreestanding?
  2. In web_run, why is audio_due_x256 kept in 1/256 ms instead of whole milliseconds? Compute how far a whole-millisecond version (+= 6 per block) would drift in one minute.
  3. Change AHEAD in your head to 512 and to 16384. What would the user notice in each case?
  4. Add an entry to web_layout for a new field in song_t. What else must change in editor.js, and what would happen if you forgot the web_layout change but added the field in the middle of the struct?
  5. measureTick uses Math.hypot(c, s) / n. Show that this is 1 when every event falls on a multiple of T, and close to 0 when the times are random.
  6. Why does the importer finish by loading its output through web_song_read instead of trusting its own writer?

← Lesson 9: VGM export and the SysEx link · Contents · Lesson 11: What comes from FM1-GM →

Clone this wiki locally