Developer guide¶
How the emulator works inside, for changing or extending it. To write scripts instead, start with Start a script.
Files¶
index.html The whole interface: grid, toolbar, side panel, sound, MIDI
serve.js Development server, and live reload over a WebSocket
engine/
grid-api.js Grid, LEDs, MIDI and metro timing (the "hardware")
sound.js Everything that plays: synth parts, drum kit, reverb, delay, chorus
synth.js The synth models (one voice's oscillators and filter; Karplus–Strong strings)
drums.js The drum kit: seven synthesised drums (from the Mangler in WAV.builder)
lua-loader.js Runs a script in Fengari and gives it the iii functions
iii-lib.js Parts of iii's lib.lua, run before every script
doc-extractor.js Reads the script's comments for the Docs tab and Live Manual
script-importer.js Fetches a script and README from GitHub or Codeberg
lua-minifier.js ↓ Device: strips comments, checks for code Lua 5.5 refuses
scripts/
manifest.json The bundled scripts in the script list
external.json Scripts by others: names and repository links only
template/ Starting point for new scripts
docs/, mkdocs.yml This documentation site
There is no build step. The page loads Fengari (Lua 5.3 in JavaScript) and marked from a CDN.
How a script runs¶
LuaLoader._execute(source, name, dir) in lua-loader.js:
- Resets the grid and stops all metros and notes.
- Creates a new Lua state and opens the standard libraries.
- Adds the iii functions:
grid_*,midi_*,metro,fs_*,pset_*,get_timeand more. Most are small wrappers aroundMonomeAPIingrid-api.js. - Runs
iii-lib.js: the parts of the firmware'slib.luathe emulator doesn't implement in JavaScript, such asmidi_outandslew. They're copied unchanged from the firmware. - Runs the script.
- Connects pad presses to
event_gridand incoming MIDI toevent_midi. Both are looked up on every call, as on the device, so a script can define or replace them later.
Errors in metro ticks and events are reported once per message, and the error is taken off the Lua stack. Otherwise every later call fails as well.
dir is the script's folder (a URL). fs_run_file and require look there for files that aren't bundled scripts.
Matching the device¶
The real API is in the firmware repo, neotrellis-monome, under neotrellis_monome_picosdk_iii/src/:
iii/lua/lib.lua: metro, MIDI helpers, pset, slewiii/vm.c: built-ins such asmidi_tx,get_timeandfs_*device_lua.c: grid functions
When you add or change a function, match what these do, including argument order and integer or float results. Where the emulator can't match the device, add it to Hardware vs. emulator.
Adding a function¶
Implement it in grid-api.js and register it in lua-loader.js, next to the others:
// lua-loader.js, in _execute()
setGlobal('my_fn', (L2) => {
lua.lua_pushinteger(L2, api.my_fn(getInt(L2, 1)));
return 1; // number of values returned to Lua
});
// grid-api.js
my_fn(value) {
return value * 2;
}
If the function is easier to write in Lua and the firmware has it in lib.lua, copy it into iii-lib.js instead.
Live reload¶
serve.js serves the folder on port 3141 and watches scripts/ and its subfolders. When a .lua file changes, it sends its path (such as leaveseqr/leaveseqr.lua) over the WebSocket at /ws. If that script is running, the emulator loads it again.
npm start # port 3141
node serve.js 8080 # another port
Testing without a browser¶
The engine runs in Node with the fengari npm package, which is useful to check that scripts still run after an engine change:
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
globalThis.window = { fengari: require('fengari') };
const store = new Map();
globalThis.localStorage = { getItem: k => store.get(k) ?? null, setItem: (k, v) => store.set(k, String(v)), removeItem: k => store.delete(k) };
const { MonomeAPI } = await import('./engine/grid-api.js');
const { LuaLoader } = await import('./engine/lua-loader.js');
const api = new MonomeAPI({ cols: 16, rows: 8 });
let notes = 0;
api.midi_note_on = () => notes++; // the synth needs Web Audio, which Node doesn't have
api.midi_note_off = () => {};
const loader = new LuaLoader({ api, onScriptError: e => console.error(e) });
await loader._execute(source, 'myscript.lua');
api.handlePadEvent(1, 1, 1); // press a pad
Run every script in scripts/manifest.json this way after changing the engine. Node on Windows can't time metros faster than about 15 ms, so check timing and sound in the browser.
Documentation¶
The documentation is built with MkDocs and the Material theme. Several pages include files from elsewhere in the repo (the README, the script standard, script READMEs) with include-markdown, so each text exists once.
pip install mkdocs-material mkdocs-include-markdown-plugin
mkdocs serve
docs/index.md is also shown in the emulator's About dialog, so keep it plain Markdown (no include-markdown).
Publishing¶
Every push to main publishes the site through .github/workflows/deploy.yml:
- It builds this documentation into
docs/of the site. - It copies the emulator (
index.html,engine/,scripts/and so on) next to it, and the rawdocs/folder asemulator-docs/for the About dialog. - It pushes the result to the
gh-pagesbranch, which GitHub Pages serves.
So the emulator is at /diii-neotrellis-emulator/ and the documentation at /diii-neotrellis-emulator/docs/. In a fork, set Settings → Pages → Source to the gh-pages branch after the first run.