Skip to content

Lesson 10 The web pages

jartificer edited this page Oct 28, 2026 · 1 revision

1. The big picture

The web/ folder is a small static website: plain HTML, CSS and JavaScript modules, with no framework, no bundler and no build step for the JavaScript. Any static file server can host it. It has two pages:

Page Files What it does
Emulator and installer index.html, app.js, emu.js, worklet.js, fm1ota.js Runs the real FM1-GM firmware in the browser on a clickable FM-1, and installs firmware on a real FM-1 over Web MIDI
Song editor editor.html, editor.js, fm1song.js, synth.js, fm1link.js A piano-roll editor for FM1-GM songs, with a preview that uses the firmware's own sound engine, and transfer of songs to and from the device or the emulator

The binary files are build outputs that are committed so the site works as is: fm1gm.wasm (the emulator, 669 KB), fm1gm.fwsc (the firmware package from Lesson 9), and build.txt (which commit they came from: currently 9e9e01c, REV 152).

The idea that makes this cheap: run the real firmware

The emulator is not a JavaScript re-implementation of the FM-1. It is the same C code as the firmware (the UI, settings, song store, sequencer and sound engine), compiled to WebAssembly. Only the bottom layer is replaced: host/gm_web.c implements the platform interface from plat.h (Lesson 1) on top of plain memory, just like the host simulator gm_host.c did in Lesson 4.

            firmware (main.c)          host simulator (gm_host.c)        browser (gm_web.c)
ui.c, settings.c, seq.c, gm.c, storage.c, link.c  ← identical source in all three
            plat_fm1.c: GPIO, LCD,     fake time, fake NOR,              JS sets buttons, reads
            I2S DMA, real flash        a script language                 the screen, takes audio

This gives three things for the price of one. Users can try the firmware before installing it. The song editor can preview songs with the exact sounds the device makes. And the tests (Section 6) can talk to the real link.c protocol code with no hardware.

How the pieces talk

index.html ─ app.js ─┬─ emu.js ── fm1gm.wasm (gm_web.c + firmware sources)
                     │     └─ worklet.js (audio thread)
                     ├─ fm1ota.js ── Web MIDI ── real FM-1 (Lesson 9)
                     └─ BroadcastChannel "fm1gm-link" ──┐
                                                        │
editor.html ─ editor.js ─┬─ fm1song.js (song format, MIDI export)
                         ├─ synth.js ── its own fm1gm.wasm instance + worklet.js
                         └─ fm1link.js ─┬─ Web MIDI ── real FM-1 (link.c)
                                       └─ BroadcastChannel ── emulator tab (link.c)

Notice that the editor talks to the emulator in another tab using exactly the same protocol it uses with a real device. Only the transport differs. That is the backend-seam idea from Lesson 9 again, and it is the main thing to take away from this lesson.

Why ES modules and no build step

Each file starts with import ... from "./other.js" and the HTML loads one entry point with <script type="module">. Browsers resolve those imports themselves. The cost is a few extra HTTP requests. The benefit is that what you read in the repo is exactly what runs, and that Node can import the same files in the tests (tests/web_song_test.mjs does import * as S from "../web/fm1song.js"). One consequence: the page must be served over http(s). Opening index.html as a file fails, and app.js says so in its error message.

2. The emulator: firmware in WebAssembly

Building it: host/build_web.sh

The whole build is two clang commands. Each of the three C files (gm_web.c, gm.c, seq.c) is compiled with:

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

and linked with wasm-ld options --no-entry --export-dynamic --initial-memory=8388608 -z stack-size=262144. A few of these deserve a sentence each:

  • --target=wasm32 -ffreestanding -nostdlib: no C library at all, and no Emscripten. WebAssembly by itself has no printf, no files, no malloc unless you bring them. The firmware never needed a C library either (Lesson 1), so this works. gm_web.c supplies the four functions the compiler may call behind your back: memset, memcpy, memmove, memcmp. It turns printf into nothing with a macro.
  • -ffp-contract=off: the same flag as the firmware build. It forbids the compiler from fusing a * b + c into a single fused multiply-add, which rounds differently. With it, the emulator computes the same samples as the device, bit for bit, which is what makes the song preview trustworthy.
  • --no-entry: there is no main(). JavaScript calls exported functions instead.
  • --initial-memory=8388608: 8 MiB of linear memory, enough for the 1 MiB fake flash, the sample data and the engine's buffers. The 256 KiB stack is set explicitly because the default is small.

Exports are marked in the source with a macro:

#define WEB(name) __attribute__((export_name(#name)))
WEB(web_run) void web_run(uint32_t ms) { ... }

The result, web/fm1gm.wasm, is about 669 KB, most of it the sample data from Lesson 7.

host/gm_web.c: a simulated FM-1

This is the browser's equivalent of plat_fm1.c. Read it next to gm_host.c from Lesson 4; they are close cousins. Each piece of hardware becomes a variable that JavaScript can read or write through an exported function:

Hardware In gm_web.c JavaScript side
LCD fb[240*240] RGB565, fb_gen counter bumped on every draw web_fb(), web_fb_gen()
Buttons, keys, encoders, MASTER held_btn, held_keys, enc_acc[], master web_set_buttons(), web_turn(), ...
LEDs lit_btn, lit_keys, glow_on, play_red web_lit_buttons(), ...
USB-MIDI in and out two 256-entry packet rings web_midi_in(), web_midi_out()
Flash nor[0x100000], with store_writes counter web_nor(), web_store_writes()
SysEx (for link.c) sx_in[640], one message at a time; sx_out[16384] web_sx_in(), web_sx_out()
Audio a 16,384-frame float ring web_audio_take(), web_audio_buf()

Two details are worth studying.

The flash behaves like real NOR. st_erase() sets 4 KiB to 0xFF. st_prog() does nor[off + i] &= p[i]: programming can only clear bits, never set them. If storage code ever forgot an erase, the emulator would corrupt data just like the device would, instead of hiding the bug. This is the same strict fake as in Lesson 4.

Time is driven from outside. web_run(ms) advances the device by ms milliseconds, one at a time. Each millisecond it renders any audio blocks that are due, services link.c, and runs either a full UI frame (every 16 ms) or just the input scan:

while (audio_due_x256 <= now_ms * 256u) {          /* 256-frame blocks */
    seq_render(blk, 256, master);
    ... convert to float, push into aq ...
    audio_due_x256 += 256u * 256u * 1000u / 44100u;  /* 5.805 ms, in 1/256 ms */
}

256 frames at 44.1 kHz last 5.805 ms, which is not a whole number of milliseconds. Storing the due time in units of 1/256 ms is a fixed-point trick: it keeps the fraction (1486/256 = 5.805) without using floating point. The same trick appears in the firmware's timers.

The audio ring uses a power-of-two size so wrap-around is a mask (& (AQ - 1u)), and free-running 32-bit counters aq_w and aq_r. If nobody takes the audio (no sound switched on yet), it drops the oldest frames: if (aq_w - aq_r > AQ) aq_r = aq_w - AQ;. Those are the same SPSC-ring habits as Lesson 1.

Crossing the boundary: pointers and views

WebAssembly functions can only pass numbers. So how does JavaScript read the screen? The C side returns a pointer, which in wasm is just an offset into the module's linear memory. JavaScript wraps that region in a typed array view:

const fb = new Uint16Array(this.x.memory.buffer, this.x.web_fb(), 240 * 240);

No copy is made: fb[i] reads the wasm memory directly. The emulator then uses .slice() whenever it keeps data for later (audio blocks, SysEx answers, the flash it saves), because the C code will overwrite that memory on the next call. Learn this pattern; it is how every C-to-JavaScript bridge works under the hood.

web/emu.js: the driver

Emulator.load() fetches the wasm file, instantiates it with no imports (the module needs nothing from JavaScript), restores the flash from IndexedDB if a saved copy exists, and boots. From then on the main question is: who decides how fast device time runs?

The answer is the audio. Browsers play sound on a separate real-time thread at exactly 44,100 frames per second. If the emulator ran on setInterval instead, any timing jitter would cause clicks or gaps. So:

  1. worklet.js (the audio thread) plays queued blocks and, every 4 render quanta (4 × 128 frames, about 11.6 ms), posts back how many frames it has played.
  2. On that message, audioTick() computes how many frames are queued but unplayed. If fewer than AHEAD = 3,072 frames (about 70 ms) are queued, it runs the device for just enough milliseconds to top it up.
  3. It takes the rendered frames out of the wasm ring, copies them, and posts them to the worklet. postMessage(blk, [blk.buffer]) transfers the buffer instead of copying it a second time.

The device therefore runs about 70 ms ahead of what you hear, and never drifts, because the sound card's clock paces everything. Before the user clicks "Switch on" (browsers forbid sound before a user gesture), clockTick() runs every 10 ms off performance.now() instead, capped at 100 ms per tick so a background tab does not run minutes of device time at once when it wakes.

Other small details in emu.js:

  • Short taps. A mouse click can be shorter than the device's key-scan period, so a press and release could both happen between two scans and be missed. control() keeps every press down for at least TAP = 40 ms of device time by scheduling the release in ups.
  • Saving the flash. After each run(), if web_store_writes() changed, a save to IndexedDB is scheduled 400 ms later. A burst of writes becomes one save (this is called debouncing).
  • Painting. paint() only redraws when fb_gen changed. It byte-swaps each pixel back (the panel takes them big-endian) and expands 5-6-5 bits to 8-8-8.
  • SysEx. sysex() queues messages and sxPump() feeds them one at a time, only when web_sx_busy() says the device took the previous one. That reproduces the real device's one-message buffer.

web/worklet.js: the audio thread

Thirty lines. An AudioWorkletProcessor runs on the browser's audio thread, and process() is called for every 128 frames. It copies from a queue of Float32Array blocks into the left and right outputs, and outputs silence if the queue is empty. It never allocates or waits, because anything slow on the audio thread is an audible glitch. This is the same rule as the firmware's audio ISR, in a different language.

3. The main page: index.html and app.js

index.html is mostly markup and CSS: a header with status fields, the device drawing (a <canvas> for the screen, empty containers for buttons and keys, .dial elements for the encoders), a MIDI keyboard selector, and the "Install on your FM-1" panel. All behaviour lives in app.js, loaded as a module at the end. app.js reads in four parts.

Part 1: building the panel

The buttons and the 27 keys are generated by JavaScript, not written out in HTML. The key layout is computed from music theory rather than hard-coded: key k is note (5 + k) % 12 (the keyboard starts at F3), a name containing # is a sharp, and sharps are placed over the gap between naturals. If you ever need to draw a keyboard, this loop is a compact model.

Every control goes through one helper, hold(el, fn), which calls fn(true) on press and fn(false) on release. It handles three kinds of input in one place:

  • Pointer events (pointerdown, pointerup, pointercancel) cover mouse, touch and pen. setPointerCapture() makes sure the release arrives at the same element even if the finger slides off it, so a button can never get stuck down.
  • Keyboard (Space, Enter) for accessibility, ignoring auto-repeat (e.repeat).
  • On window blur, every key and button is released, because a key held while the tab loses focus never sends its keyup.

The encoders accept dragging (one detent per 10 pixels, scaled to the page zoom), the mouse wheel (with an accumulator, because trackpads send many tiny deltas), and arrow keys. MASTER is different: it is a pot with an absolute value from 0 to 4096, so it calls emu.master() instead of emu.turn(). The computer keyboard also plays the keys (KEYMAP: Z S X D C ... is the classic tracker layout), except while you type in a form field.

Part 2: the render loop

frame() runs on every requestAnimationFrame (about 60 times a second, and paused by the browser when the tab is hidden). It asks the emulator to paint the screen into an ImageData (only if fb_gen changed) and toggles CSS classes for each light. Note the separation: the device runs on the audio clock (Section 2), the drawing runs on the display clock, and neither waits for the other.

Part 3: start-up, and the bridge to the editor

boot() loads the emulator and shows whether the flash was restored from last time. If it fails, it shows a helpful message (WebAssembly missing, or the page opened from a file) instead of a blank page.

bridge() connects the emulator to the song editor in another tab, using a BroadcastChannel named fm1gm-link. A BroadcastChannel delivers messages to every other page of the same origin that opened a channel with the same name. Messages from the editor are tagged from: "editor" and are passed to emu.sysex(). SysEx the emulator sends back is posted with from: "emu". The tags matter because a channel does not care who is listening: with two editor tabs open, each editor would otherwise take the other's requests for the emulator's answers.

The "Switch on" button calls emu.startAudio(). It has to be a click, because browsers only allow an AudioContext to start from a user gesture. The "Erase flash" button needs a second click within 4 seconds, a simple and friendly alternative to a confirmation dialog.

Part 4: MIDI and the installer

After "Connect MIDI", the page asks for navigator.requestMIDIAccess({sysex: true}). SysEx needs explicit permission because it can reprogram devices, which is exactly what this page does. Two things then become possible:

  • Playing the emulator from a MIDI keyboard. The selected input's messages go to emu.midi(), but only channel messages and Start/Clock (0xFA, 0xFC), a narrower version of the filter in the firmware's ep1_rx().
  • Installing on a real FM-1. detect() creates an OTA.Updater with OTA.webMidiBackend(access) and calls find(), exactly as Lesson 9 described. It runs again automatically 800 ms after any port appears or disappears (statechange), which is how the page notices the device rebooting into the loader.

Where does the package come from? The comment above latestRelease() explains a neat workaround. The page wants to install the newest GitHub release, and wants the release's download counter to count installs. But GitHub's release download URLs send no CORS headers, so JavaScript on another site cannot read them. So the page:

  1. asks the GitHub API for the latest release and its fm1gm.fwsc asset (with its SHA-256);
  2. downloads the same file from raw.githubusercontent.com at the release's tag, which does allow CORS;
  3. checks its SHA-256 with crypto.subtle.digest() against the asset's, so it is provably the release's file;
  4. after a successful install, fetches the asset URL once with mode: "no-cors". The page cannot read that response, but GitHub counts the download.

If GitHub is unreachable or rate-limited (60 API calls an hour without a login), it falls back to the fm1gm.fwsc bundled with the page. You can also pick any file yourself, and OTA.loadPackage() applies the same checks as the Python installer.

The install itself is a confirmation box, then up.install(dev, pkg, step), with step turning progress events into a bar and a log. All the hard work is in fm1ota.js, which Lesson 9 covered.

4. The song editor

The editor is three modules with clean jobs: fm1song.js knows the song format, synth.js plays a song, editor.js is the user interface. A fourth, fm1link.js, moves songs to and from a device (Section 5).

fm1song.js: the firmware's format, in JavaScript

The song is plain data:

{tempo: 120, bars: 4, quant: 4, tracks: [
  {chan, prog, vol, pan, rev, vel, mute, solo, transpose, notes: [{tick, note, len}, ...]},
  ... 16 tracks
]}

songBytes() and parseSong() are a line-by-line port of song_stream() and song_parse() in seq.c, which Lesson 2 covered in detail: a 5-byte header, 8 bytes per track, then each track's notes as variable-length numbers with delta ticks and the "same length as the previous note" bit (note | 0x80). If you worked through Lesson 2's 154-byte example, you can check it against this file.

What makes the port trustworthy is that it copies the firmware's strictness, not just its happy path:

  • problems() repeats every range check of song_parse() (tempo 30..300, bars 1..128, velocity 1..127, transpose -24..24, at most 8,192 notes) and returns a list of human-readable problems. The editor shows them before you try to send anything, and songBytes() refuses to encode a song that has any.
  • parseSong() rejects a number longer than 3 bytes ("nothing in a song needs more than 21 bits"), a file that ends early, and any bytes left over after the last note. Rejecting trailing garbage is what tells you the whole file was understood, rather than just a valid-looking prefix.
  • tr << 24 >> 24 turns the stored byte back into a signed transpose: shift the byte to the top of a 32-bit integer, then arithmetic-shift it back down so the sign bit spreads. It is the JavaScript spelling of C's (int8_t)tr.

songToMidi() exports a Standard MIDI File, which is worth reading if you have never built one. An SMF is chunks (MThd, then one MTrk per track), each a 4-letter tag plus a 32-bit big-endian length. Inside a track, every event is a delta time (a variable-length number) followed by the event bytes. The function makes a first track with the name, a GM System On SysEx, the tempo (FF 51, in microseconds per quarter note) and a 4/4 time signature, then one track per FM1-GM track that has notes. Two subtle rules come from the device:

  • Several tracks can share a MIDI channel, but a channel has only one program, volume and pan. The FM-1 uses the settings of the lowest-numbered track on the channel, so the export does the same (owner map).
  • At the same tick, note-offs must come before note-ons, or a repeated note would be cut off immediately. Each event carries a sort key (off = 5, on = 10), and events are sorted by tick, then by that key.

Channel 10 (index 9) is the drum channel: no program change is sent, and drum notes outside the FM-1's kit (35..81) are left out because the device would not play them.

synth.js: preview with the device's own sounds

Preview loads its own instance of fm1gm.wasm, separate from any emulator tab, and boots it with an empty flash. It never uses the firmware's sequencer or UI. Instead it calls web_gm(st, d1, d2), an export that sends a MIDI message straight to the sound engine (gm_midi()). The sequencing is done in JavaScript, so it can read the song as it is being edited:

  • step() advances one millisecond. The position pos is in fractional ticks: each millisecond adds tempo × 48 / 60000. Every whole tick up to pos is played from index, a Map from tick to the notes on it.
  • index is rebuilt lazily: an edit just sets it to null, and the next step() rebuilds it. That is why a change is heard on the next loop without stopping playback.
  • Note-offs are scheduled in device milliseconds (on list). Mute and solo follow the FM-1's rule: if any track is soloed, only soloed tracks play.
  • Time follows the audio, as in emu.js, with 4,096 frames (about 90 ms) queued.
  • The playhead must show what you hear, not what the engine has computed 90 ms ahead. The getter heard keeps a short history of [device ms, position] pairs and looks up the position at ms - queued_frames / 44.1, which is the moment now leaving the speaker.

Much of the file is about one annoying fact: browsers only allow audio to start inside a user gesture, and the details differ. start() calls ctx.resume() before awaiting anything, because Firefox only honours the gesture while still inside the event handler. The editor retries on pointerup, touchend, click and keydown, because on some phones the first touch's pointerdown does not count. These comments record real debugging. When you hit browser audio problems, read them first.

editor.js: the interface

The editor keeps the whole state in a few let variables: song, name, sel (the track shown in the piano roll) and picked (selected notes). Three small functions make the rest simple:

  • edit(), called before any change, pushes a JSON snapshot of the song onto undos (at most 200) and clears redos. Undo is "pop a snapshot and restore it". Copying the whole song every time sounds wasteful, but a song is at most about 44 KB of bytes and the snapshot is a few hundred KB of JSON at worst. It is simple and impossible to get wrong, unlike the firmware's coalesced undo in Lesson 3, which had to fit in a few KB of RAM.
  • changed(), called after any change, tells the preview, saves a draft to localStorage (inside try, because private windows can refuse), and re-renders.
  • load() replaces the song, as an undoable edit.

The piano roll is a <canvas> drawn by hand in draw(), with coordinate helpers tickX() and noteY() and its own scroll bars (the CSS in editor.html explains why: on phones the browser's own cannot be dragged, and a finger on the roll draws notes rather than scrolling). Pointer handling adds, moves, resizes and deletes notes, with snapping to the quantize grid. dedupe() enforces a device rule in the editor: two identical notes on the same tick become one, because the FM-1 would keep only the later one anyway. Wherever the editor and the device could disagree, the editor adopts the device's rule. That is the right direction.

5. The SysEx link: link.c and fm1link.js

This is the protocol the editor uses to list, read, write and delete the 64 song slots. It is FM1-GM's own; the M-UPGRADE protocol from Lesson 9 belongs to the vendor. Comparing the two is a good lesson in protocol design, because this one is simpler on purpose.

The message format

host  -> FM-1:  F0 7D 46 47 cmd args ... F7
FM-1 -> host:   F0 7D 46 47 cmd|40 status args ... F7
  • 7D is the manufacturer ID that MIDI reserves for non-commercial and educational use, so the messages cannot collide with a real company's. 46 47 is ASCII "FG", a project tag.
  • The answer echoes the command with bit 6 set (0x41 answers 0x01) and puts a status byte first: 0 done, 1 bad request (or empty slot), 2 not a song, 3 no room, 4 flash error, 5 busy.
  • Numbers are sent 7 bits per byte, low bits first (lk_num() / lk_put() in C, num() / put() in JavaScript). Note that this is the opposite order from the variable-length numbers inside a song, which are high bits first like MIDI files. Each format follows its own convention, so read the comment every time.
  • Song bytes are packed 8-into-7, low bits first, the same scheme as ota.c. A chunk of 512 song bytes becomes 586 SysEx bytes, chosen so a whole message fits the 640-byte frame buffer in usb.c (Lesson 8).
Command Request Answer
01 HELLO none protocol, song format, slots, sectors total and free, slot in use, unsaved flag, version text
02 LIST first slot up to 16 slots: used, bars, tempo, notes, bytes, sectors
03 READ slot, offset slot, offset, total length, up to 512 bytes (packed)
04 WRITE slot, length (status only)
05 DATA offset, packed bytes how many bytes the device has so far
06 DELETE slot slot
47 DONE (sent by the device on its own) slot, status, once a written song is loaded and saved

There is no checksum. USB already protects each packet with a CRC, and every answer echoes the slot and offset it refers to, so a mismatch is detectable. M-UPGRADE adds a one-byte checksum on top of that; this protocol relies on USB and on the echoed fields alone.

The device side: firmware/src/app/link.c

link.c is included in the unity build right after ui.c, and also by gm_web.c. (Its header comment says the host simulator includes it too, but host/gm_host.c does not, so treat that as a stale comment.) It asks its includer for three hooks: link_rx(), link_rx_done(), link_tx(). On the device, main.c maps those onto the same single-slot SysEx mailbox that ota.c uses (Lesson 9). In the browser, gm_web.c maps them onto sx_in and sx_out. Same hook pattern, third time in these lessons.

In the device's main loop, link_service() runs before ota_service(). If a frame does not start with 7D 46 47, link_service() returns without releasing it, so ota.c gets its turn at the same frame. Two protocols share one buffer without knowing about each other.

link_service() is a small state machine with four modes:

         READ offset 0                 WRITE                 last DATA
LK_IDLE ───────────────> LK_COPY      LK_IDLE ──────> LK_RECV ──────────> LK_LOAD ─── sequencer stopped ──> load + save, send DONE, LK_IDLE
 (READ at offset > 0 served from the copy)                                          └── still playing after 3 s ──> DONE status 5

The interesting decisions:

  • A READ at offset 0 takes a snapshot. lk_copy() copies the whole song into lk_buf (48 KiB, placed in the RAM pool with OM_POOL). Later READs are served from that copy. If you change the song on the device halfway through a transfer, the editor still receives one consistent song, not half old and half new. Later READs are refused unless they name the same slot and an offset inside the copy.
  • DATA carries its offset, and duplicates are harmless. If offset == have, the bytes are appended. If offset < have, the device has already got those bytes, so it ignores them and simply answers with have again. This matters because the host retries when an answer is lost: the request arrived, the device appended it, but the reply vanished. Without this rule, the retry would append the same bytes twice. This property (doing a request twice has the same effect as doing it once) is called idempotence, and it is the key idea for any protocol with retries.
  • Loading waits for a safe moment. When the last byte arrives, the device calls ui_stop() and enters LK_LOAD. On each later pass, lk_load() checks whether the sequencer is idle. Only then does it set seq_lock, parse the bytes with seq_import(lk_get) (the parser from Lesson 2, fed by a callback that reads from lk_buf), save to the slot if one was named, and send DONE. Replacing the song while the audio interrupt might be reading it would be a data race, which is exactly the problem seq_lock solved in Lesson 3.
  • A bad song changes nothing. If seq_import() fails, the status is 2 and the song in memory is as it was: seq_import() first copies the current song into the undo buffer, parses into the live song, and on failure copies it back. The tests check this.

The host side: web/fm1link.js

SongLink wraps any transport with send(bytes), read(ms), drain() and close(). That is the same link interface as fm1ota.js, so there are two transports:

  • Web MIDI: findDevice(access) reuses webMidiBackend() and pairOutput() from fm1ota.js, tries HELLO on each port whose name matches PORT_RE, and returns the first that answers. If none answers but a port answers the M-UPGRADE handshake instead, it reports that identity, so the editor can say "found FM-1_015, which does not answer the song editor: install the latest FM1-GM". A good error message names what was found.
  • The emulator in another tab: ChannelTransport implements the same four methods on top of the BroadcastChannel from Section 3. Its read(ms) is the same queue-plus-waiters pattern as WebMidiLink.read().

The request logic is in two methods:

  • wait(cmd, ms) returns the next answer to cmd. Answers to other commands are kept in box rather than thrown away, so an early or late answer is not lost.
  • ask(cmd, args, {ms, tries, accept}) sends, waits, and retries up to tries times. The accept predicate filters out stale answers: read() accepts only an answer whose slot and offset match the request, and write() accepts only an answer whose byte count has moved past this chunk (or behind it, which means the device lost data and the host must resend from there).

write() sets its next offset to whatever the device reports (off = num(r.d, 0, 3)), not to what it sent. The device is the authority on what it has. After the last chunk, it keeps sending HELLO every 700 ms while waiting for DONE. The comment explains the double purpose: it polls for the result, and while the emulator's sound is off, every message it receives runs it for 2 ms, which keeps a background emulator tab moving.

This is a good protocol to imitate for your own projects: small fixed header, echo of the request in the answer, offsets instead of sequence numbers, idempotent writes, the device as the authority, and one message in flight at a time.

6. How it is tested

Two Node scripts test the web code without a browser. tests/run_tests.sh runs both when node is installed.

  • tests/web_install_test.mjs tests fm1ota.js against a simulated FM-1. It mirrors tests/install_test.py from Lesson 9, scenario for scenario.
  • tests/web_song_test.mjs tests the editor's two halves, fm1song.js and fm1link.js, plus the synth.js preview, against the real firmware: it loads web/fm1gm.wasm in Node and talks to the real link.c inside it.

The second one is the interesting one, and it rests on one small class:

class WasmTransport {
    send(p) { this.inq.push(p.slice(1, -1)); return true; }
    pump() {   // one millisecond of device time
        if (this.inq.length && !x.web_sx_busy()) { ...copy into web_sx_in_buf, web_sx_in(n)... }
        x.web_run(1);
        ...collect web_sx_out into outq, split at F7...
    }
    async read(ms) {
        for (let t = 0; t < ms && !this.outq.length; t++) this.pump();
        return this.outq.shift() || null;
    }
}

It implements the transport interface from Section 5, so SongLink cannot tell it apart from Web MIDI. And read(ms) does not wait in real time: it runs the device one simulated millisecond at a time until an answer appears or ms simulated milliseconds have passed. The whole test runs in a second or two, and it is completely deterministic. Simulated time is the same idea as in the host simulator of Lesson 4.

What it checks, in groups (about 40 checks; I ran it in this session and all passed):

  • Format. A pseudo-random song survives bytes → song → bytes. The header matches the firmware's. A cut-off song is refused. A new song is 149 bytes (5 + 16 × 8 + 16).
  • MIDI export. Chunk structure, a note-on for every note, drums on channel 10 with no program change, transposition, solo and mute.
  • The link. HELLO reports protocol 1, format 2, 64 slots, and 71 free sectors on a fresh flash. A song written to slot 6 lists correctly, reads back byte for byte, and is also the song in memory. A song with the maximum 8,192 notes goes over in exactly ceil(bytes / 512) parts. A broken song (first byte changed to 9) is refused with status 2, and the song in memory and the slots are unchanged. Reading an empty slot says so. Delete frees every sector again.
  • The preview. Silence before Play; sound after it (RMS above a threshold); notes in the right order with the drum on channel 10; the loop repeats every 2 s at 120 BPM; an edit is heard while playing; a muted track goes quiet; Stop silences it.

Note the test random generator: r = (r * 1103515245 + 12345) >>> 0. That is a classic linear congruential generator with a fixed seed, so the "random" song is the same on every run. Tests with randomness must be reproducible, or a failure you cannot repeat is worth very little.

One thing to keep in mind

The tests load the committed web/fm1gm.wasm, not a fresh build. If you change link.c or seq.c and forget to run sh host/build_web.sh, the tests still pass, against the old code. web/build.txt records which commit the binaries were built from (currently 9e9e01c), which gives you a way to notice. When you work on this part, rebuild first; it needs only clang and wasm-ld.

7. Exercises

  1. Serve it. Run python3 -m http.server in web/ and open the page. Then open index.html directly as a file and read the error. Explain why ES modules and fetch("fm1gm.wasm") fail from file://.
  2. Call the firmware from Node. In a 15-line .mjs script, instantiate web/fm1gm.wasm, call web_boot(1) and web_run(500), then read the 240×240 framebuffer through web_fb() and count how many pixels are not black. Then press PLAY with web_set_buttons(), run 100 ms, release, and check web_playing().
  3. Byte order. gm_web.c stores pixels byte-swapped and emu.js swaps them back. Write out, for the colour pure red (RGB565 0xF800), the 16-bit value in fb[] and the three 8-bit values paint() produces.
  4. Fixed point. audio_due_x256 += 256 * 256 * 1000 / 44100 truncates 1486.08 to 1486. How many extra frames per hour does the emulator render compared with exact 44.1 kHz? Explain why this never causes a drift you can hear in the browser. (Hint: who decides how far ahead the device runs?)
  5. Encode a number twice. Write the number 300 as link.c sends it in 2 bytes, and as a song's variable-length number. Explain the difference in one sentence.
  6. Idempotence. In link.c, case 5 (DATA): walk through what happens when the host sends the chunk at offset 512 twice because the first answer was lost. Then what happens if a chunk at offset 1536 arrives when have is 1024? Which answer does the host get, and what does write() in fm1link.js do with it?
  7. A new command. Design (on paper) a 07 RENAME-style command for link.c, or any small command you like: the request, the answer, the status codes, and which mode it is allowed in. Then write the JavaScript method for SongLink using ask() with an accept predicate.
  8. A third transport. SongLink works over Web MIDI, a BroadcastChannel and (in the tests) wasm in Node. Sketch a fourth: a WebSocket to a server that relays to a device. Which four methods do you need, and what does read(ms) look like?
  9. Strictness. Change one byte at the end of a valid .fm1song file so it has a trailing extra byte, and confirm parseSong() refuses it. Then argue for or against accepting trailing bytes.
  10. Build your own emulator layer. Pick a tiny C program of your own that reads buttons and draws to a screen through a small interface like plat.h. Write a web.c for it with WEB(...) exports, build it with the two clang lines from build_web.sh, and drive it from a page. This is the exact pattern this project uses, at a size you can finish in an evening.

If you can do 6, 8 and 10 without looking, you understand this part of the code well enough to write it. This was the last lesson in the series.


← Lesson 9: The firmware installer · Contents

Clone this wiki locally