Repository navigation
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.
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=offmatches the firmware and the host build. It stops the compiler from fusinga*b+cinto 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 markedWEB(...)becomes a function JavaScript can call asinstance.exports.web_xxx. Pointers come back as plain numbers, offsets intoinstance.exports.memory. -
--initial-memory=16777216: 16 MiB, fixed. The C side uses static arrays only (the 1 MiB flash, the 1 MiBiobuffer, the audio queue), so nothing ever needs to grow. - The units are the same list as the host build:
drive_web.cplusopn2.c,psg.c,mdsnd.c,song.c,player.c,presets.c,presets_fm.c,vgm.c. The committedweb/fm1trax.wasmis 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.
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.
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.jsis anAudioWorkletProcessor. Itsprocess()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()computesneed = AHEAD - (sent - played), withAHEAD = 3072frames (about 70 ms). It runs the device forneed / 44.1milliseconds, takes the rendered frames withweb_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 = 40ms 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 whenweb_fb_genhas not changed, else converts RGB565 to RGBA. (Note the byte swap back:((v >> 8) | (v << 8)) & 0xFFFF.) -
Flash persistence: when
web_store_writeschanges, a 400 ms debounce saves a copy ofnorto IndexedDB (database"fm1drive-emulator", an old name kept so saved songs survive). On load, a saved flash of the right size is copied back beforeweb_boot(0). -
SysEx:
sysex(msg)queues a message;sxPump()hands it to the module only whenweb_sx_busy()is 0. If the tab has no audio running, it callsrun(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.
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.
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:
-
Read the VGM (
readVgm): header, commands, data blocks, GD3 tag. -
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 angle2πt/Tand 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. -
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 with768 * log2(f / C0)), PSG periods and volumes, DAC sample starts. -
Find the row grid (
findGrid): try every pair of speedss0, 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. -
Choose the cells by analysis through synthesis. The importer contains a model of FM1-TRAX's driver (
simulate, tick for tick asplayer.cdoes 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. -
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). -
Write the
.fm1dstream (writeSong). The editor then loads it through the realsong_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.
tests/run_tests.sh rebuilds the wasm when clang has the wasm32 target, then runs, under Node:
-
web_editor_test.mjs: loadsfm1trax.wasmin Node, edits a song throughweb_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.
- 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.cis 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.
- Run
sh host/build_web.sh(or read it) and list what--no-entryand--export-dynamiceach do. What would break without-ffreestanding? - In
web_run, why isaudio_due_x256kept in 1/256 ms instead of whole milliseconds? Compute how far a whole-millisecond version (+= 6per block) would drift in one minute. - Change
AHEADin your head to 512 and to 16384. What would the user notice in each case? - Add an entry to
web_layoutfor a new field insong_t. What else must change ineditor.js, and what would happen if you forgot theweb_layoutchange but added the field in the middle of the struct? -
measureTickusesMath.hypot(c, s) / n. Show that this is 1 when every event falls on a multiple ofT, and close to 0 when the times are random. - Why does the importer finish by loading its output through
web_song_readinstead of trusting its own writer?
← Lesson 9: VGM export and the SysEx link · Contents · Lesson 11: What comes from FM1-GM →
- 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