Repository navigation
Lesson 10 The web pages
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 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.
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.
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.
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_HOSTand 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 noprintf, no files, nomallocunless you bring them. The firmware never needed a C library either (Lesson 1), so this works.gm_web.csupplies the four functions the compiler may call behind your back:memset,memcpy,memmove,memcmp. It turnsprintfinto nothing with a macro. -
-ffp-contract=off: the same flag as the firmware build. It forbids the compiler from fusinga * b + cinto 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 nomain(). 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.
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.
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.
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:
-
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. - On that message,
audioTick()computes how many frames are queued but unplayed. If fewer thanAHEAD= 3,072 frames (about 70 ms) are queued, it runs the device for just enough milliseconds to top it up. - 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 leastTAP= 40 ms of device time by scheduling the release inups. -
Saving the flash. After each
run(), ifweb_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 whenfb_genchanged. 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 andsxPump()feeds them one at a time, only whenweb_sx_busy()says the device took the previous one. That reproduces the real device's one-message buffer.
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.
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.
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 itskeyup.
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.
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.
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.
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'sep1_rx(). -
Installing on a real FM-1.
detect()creates anOTA.UpdaterwithOTA.webMidiBackend(access)and callsfind(), 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:
- asks the GitHub API for the latest release and its
fm1gm.fwscasset (with its SHA-256); - downloads the same file from
raw.githubusercontent.comat the release's tag, which does allow CORS; - checks its SHA-256 with
crypto.subtle.digest()against the asset's, so it is provably the release's file; - 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.
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).
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 ofsong_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, andsongBytes()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 >> 24turns 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 (
ownermap). - 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.
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 positionposis in fractional ticks: each millisecond addstempo × 48 / 60000. Every whole tick up toposis played fromindex, aMapfrom tick to the notes on it. -
indexis rebuilt lazily: an edit just sets it tonull, and the nextstep()rebuilds it. That is why a change is heard on the next loop without stopping playback. - Note-offs are scheduled in device milliseconds (
onlist). 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
heardkeeps a short history of[device ms, position]pairs and looks up the position atms - 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.
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 ontoundos(at most 200) and clearsredos. 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 tolocalStorage(insidetry, 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.
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.
host -> FM-1: F0 7D 46 47 cmd args ... F7
FM-1 -> host: F0 7D 46 47 cmd|40 status args ... F7
-
7Dis the manufacturer ID that MIDI reserves for non-commercial and educational use, so the messages cannot collide with a real company's.46 47is ASCII "FG", a project tag. - The answer echoes the command with bit 6 set (
0x41answers0x01) 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 inusb.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.
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 intolk_buf(48 KiB, placed in the RAM pool withOM_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. Ifoffset < have, the device has already got those bytes, so it ignores them and simply answers withhaveagain. 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 entersLK_LOAD. On each later pass,lk_load()checks whether the sequencer is idle. Only then does it setseq_lock, parse the bytes withseq_import(lk_get)(the parser from Lesson 2, fed by a callback that reads fromlk_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 problemseq_locksolved 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.
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)reuseswebMidiBackend()andpairOutput()fromfm1ota.js, tries HELLO on each port whose name matchesPORT_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:
ChannelTransportimplements the same four methods on top of theBroadcastChannelfrom Section 3. Itsread(ms)is the same queue-plus-waiters pattern asWebMidiLink.read().
The request logic is in two methods:
-
wait(cmd, ms)returns the next answer tocmd. Answers to other commands are kept inboxrather than thrown away, so an early or late answer is not lost. -
ask(cmd, args, {ms, tries, accept})sends, waits, and retries up totriestimes. Theacceptpredicate filters out stale answers:read()accepts only an answer whose slot and offset match the request, andwrite()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.
Two Node scripts test the web code without a browser. tests/run_tests.sh runs both when node is installed.
-
tests/web_install_test.mjstestsfm1ota.jsagainst a simulated FM-1. It mirrorstests/install_test.pyfrom Lesson 9, scenario for scenario. -
tests/web_song_test.mjstests the editor's two halves,fm1song.jsandfm1link.js, plus thesynth.jspreview, against the real firmware: it loadsweb/fm1gm.wasmin Node and talks to the reallink.cinside 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.
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.
-
Serve it. Run
python3 -m http.serverinweb/and open the page. Then openindex.htmldirectly as a file and read the error. Explain why ES modules andfetch("fm1gm.wasm")fail fromfile://. -
Call the firmware from Node. In a 15-line
.mjsscript, instantiateweb/fm1gm.wasm, callweb_boot(1)andweb_run(500), then read the 240×240 framebuffer throughweb_fb()and count how many pixels are not black. Then press PLAY withweb_set_buttons(), run 100 ms, release, and checkweb_playing(). -
Byte order.
gm_web.cstores pixels byte-swapped andemu.jsswaps them back. Write out, for the colour pure red (RGB5650xF800), the 16-bit value infb[]and the three 8-bit valuespaint()produces. -
Fixed point.
audio_due_x256 += 256 * 256 * 1000 / 44100truncates 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?) -
Encode a number twice. Write the number 300 as
link.csends it in 2 bytes, and as a song's variable-length number. Explain the difference in one sentence. -
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 whenhaveis 1024? Which answer does the host get, and what doeswrite()infm1link.jsdo with it? -
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 forSongLinkusingask()with anacceptpredicate. -
A third transport.
SongLinkworks 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 doesread(ms)look like? -
Strictness. Change one byte at the end of a valid
.fm1songfile so it has a trailing extra byte, and confirmparseSong()refuses it. Then argue for or against accepting trailing bytes. -
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 aweb.cfor it withWEB(...)exports, build it with the twoclanglines frombuild_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.