Developer guide
For people who want to read, build, or modify Lens. If you only want to write patches, read loupe.md instead.
This guide is the map. When it disagrees with the code, the code wins. The C runtime in particular is the source of truth for what every kernel does.
Repo layout
compiler/ Loupe source -> snapshot pipeline (pure JavaScript)
runtime/ the C runtime, the hardware shell (main.cpp), and the snapshot decoder
cli/ the dev CLI and the sysex wire protocol
web/ the web editor (the primary user interface)
tools/ build helpers and lookup-table generators
patches/ example patches
docs/ this documentation
attic/ prototype, shelved notes, and gate scripts (gitignored)
prelude.loupe the standard library, loaded before every patch
CMakeLists.txt, pico_sdk_import.cmake, LICENSE, package.json, ComputerCard.h
How it fits together
The compiler is pure JavaScript on the host. It turns a patch into a flat binary snapshot. The runtime is C on the card: it decodes the snapshot into a wired graph and walks it once per audio sample, at 48 kHz. No scheduling decisions happen at run time; the order, the rates, the wiring and the core partition are all baked into the snapshot.
The whole design is one flat list of slots. Each slot holds a 12-bit value (or a small state struct whose first field is that value) and a kernel function pointer. A walk step is one indirect call per slot. The compiler does the hard work; the runtime stays dumb.
The compiler pipeline
Each stage is one file in compiler/ and reads in isolation. compile-pipeline.js
chains them.
| Stage | File | Job |
|---|---|---|
| Reader | reader.js |
Loupe text to AST (S-expressions) |
| Expander | expander.js |
Resolve names, inline fn, expand special forms (<-, use, tape, lens, score, normal, sugar) |
| Lowerer | lowerer.js |
Build the slot graph; select a kernel per node; assign tape/audio buffers |
| Scheduler | scheduler.js |
Global topological sort; dual-core partition; single-writer verify |
| Snapshot | snapshot.js |
Binary-encode the scheduled graph |
prelude.loupe is loaded into the base environment before every patch. It holds
every constant, hardware-jack name, scale, rhythm, and builtin signature with
kwargs and a docstring. A patch can shadow any prelude binding.
Reader turns source text into numbers, symbols, keywords and lists.
Expander resolves names against the prelude and patch environment, inlines
fn calls, and expands the special forms: the <- cable, use, tape,
audio, lens, score, normal, and the sugar forms. It returns a flat list
of slot records with resolved inputs. Op-lens calls ((thru ops idx) applied to
an argument) are inlined here, so the later stages never see a half-applied lens.
The <- cable chain (threadStages)
A cable (<- DEST a b c) writes one signal to a destination. The signal can be
written as one nested expression or as a flat chain, and the two compile to the
same graph. The expander chooses by counting the top-level forms under <-
(splitArgs separates positional stages from cable-level kwargs):
0positionals: the value is0.1positional: it is the value, used verbatim. A hand-nested expression like(<- out (vca (lpf (sine p) :cut 1500) env))is a single form, so it takes this path and nothing is rewritten.2+positionals:threadStagesfolds them right-to-left into the nested form. The rightmost stage is the source; each stage to its left receives the running signal as its first argument.
Two properties keep the chain form and the nested form from interfering:
- Threading only runs with 2+ top-level forms, so an explicit nest (one form) never enters it.
- Threading is shallow. For each stage it inserts the signal between the head
and the rest (
[head, signal, ...stage.slice(1)]); it never recurses into the sub-expressions a stage already contains. Soenv,:cut 1500, and the source’s own arguments are copied through untouched.
The thread always lands the signal in a stage’s first input, which is why Loupe’s
convention is that a function’s first parameter is its signal input. A chain stage
must omit the input it is threading; writing it explicitly (e.g. (lpf :in x ...)
mid-chain) supplies that input twice. The output of this is plain nested
application, so the lowerer has no chain concept.
<- is a statement, handled by the body processors (patch body, fn body, on
region); it only ever defines a destination on its own line. It is not a value,
so it cannot be nested inside an expression. expandNode rejects a nested <-
with an error rather than dropping the write (which it did silently before). To
write a signal somewhere and keep using it, name it with def and write it from
separate lines.
Lowerer turns the expanded graph into slots, selecting the concrete kernel name for each node from its argument pattern (for example, a record-head cable becomes a per-sample or per-cell variant), and assigns buffer objects to tape, audio and wave nodes. It also records the state-field schema for each kernel family, which the single-writer verifier uses.
Scheduler produces one global topological order over every slot, partitions
it between the two cores by estimated cycle cost, and runs the single-writer
verifier. The verifier checks that no (slot, field) pair has more than one
writer; a violation makes the snapshot stage reject the graph. Stub kernels (ops
that are declared but not yet implemented) are also caught here so they never
reach a card. There is no rate classification: every slot is in the one order,
and the runtime runs every slot unconditionally every sample.
Snapshot binary-encodes the scheduled graph. Header (magic, version, slot count, master-clock walk index, buffer/terminal/kernel-registry counts), the kernel-name registry, the slot table, the buffer and terminal tables, and a trailing CRC-32. Little-endian. There is a size cap the encoder enforces before any USB round-trip.
The runtime
runtime_step in runtime/runtime.c is the per-sample entry point. Each
sample it:
- Refreshes the hardware-input scratch from the
HardwareInputsstruct. - Walks every slot once in topological order, calling each slot’s kernel. Each slot reads its inputs’ freshly written outputs from this same tick. Every slot runs every sample (the synchronous model); there is no skip tier.
- Commits record-head writes: cells written this tick become visible to tape readers next tick.
- Publishes cross-core shadows and drives the terminals: reads each terminal’s
slot output and writes it to the matching
HardwareOutputsfield.
Dispatch is a function pointer per slot. At apply time snapshot_apply resolves
each kernel name to an index into the KFN table and stores the function
pointer on the slot, so a walk step is one indirect call, no switch.
The state pool is static; there is no malloc. Audio buffers are 12-bit packed (three bytes per two cells); the 128 KB audio pool holds roughly 1.82 seconds at 48 kHz, which is the ceiling on total tape and delay length. A separate small pool holds the sequence tapes.
Free feedback. Slots walk in topological order, so each reads its inputs’ fresh outputs. Cycles are ordered with the back-edge reader before its producer, so that one read sees the previous sample’s value: a free z^-1 per loop, no op inserted. The scheduler keeps each cycle on one core (a loop split across cores would pick up an extra sample through the shadow). No cycle detection at run time; the snapshot already encodes a safe order.
Single-writer. Every state field has exactly one writer by construction, and the scheduler proves it at compile time. This is what makes the design safe without mutexes, on one core or two.
Stereo outputs
A kernel with two outputs (chorus, flanger, reverb, echo) stores the right
channel in the state word 4 bytes past its primary output. The snapshot
encodes such a read as TAG_SLOT_OUT2 (the same tag record-head position
reads use), and apply resolves it to state + soff + 4; cross-core reads of
the second field go through the same shadow scheme as values. On the language
side the prelude declares the ports (=> :l :r); the expander clones the call
per port with :port baked in, and the lowerer keys one kernel instance for
both ports on the call’s shared argument list.
Kernels
The kernel bodies all live in runtime/runtime.c as op_* functions, each
marked for RAM placement so it stays off the flash bus on the audio path. They
cover arithmetic and logic, oscillators and edge detectors, filters and
dynamics, envelopes, the drum voices (kick, snare, hat), and the tape and
record-head ops. The
name-to-id table (KTABLE) and the function-pointer table (KFN) are in the
same file; snapshot_apply.c looks names up through runtime_find_kernel.
A pure arithmetic kernel writes an int32_t to its slot output. A stateful
kernel owns a state struct at the slot output and writes its output field plus
any per-kernel state (phase accumulator, filter memory, and so on).
One walk, every sample
There is no rate tier and no skip logic. Every slot sits in one topological
order and every slot runs every sample; a slot’s cost is its kernel body plus a
fixed per-slot floor, nothing more. (An earlier skip-on-unchanged check cost
about half that floor and almost never fired on the audio path, where inputs
change every sample.) runtime_step_reference is the same unconditional walk
kept as the oracle for A/B checks.
Adaptive dual-core
The scheduler always produces a two-core partition (each slot record carries a core byte), and the firmware always builds the full dual-core path: core 0 drives the audio interrupt and rings a doorbell, core 1 runs its slice in parallel.
Cross-core reads are made race-free by the single-writer property plus a shadow: a consumer on one core reads a shadow of a producer on the other core, and the shadows are republished at the window boundary after both core walks complete. That gives cross-core edges a deterministic one-sample lag while intra-core reads stay fresh.
The partition is a single-frontier cut: core 0 is a downstream-closed set (every consumer of a core-0 slot is also core 0) holding the terminal writes and the sink side of the graph; core 1 holds everything upstream, including every generator (sources and pure feedback loops, which never peel). The cut is peeled off the SCC condensation from the sinks upward, whole components at a time (a feedback loop never splits), until core 0 carries its share of the load; the peel continues past balance if core 1 would otherwise exceed its cycle budget. The shape buys a theorem: every path from any source to a core-0 slot crosses the boundary exactly once, and to a core-1 slot never, so the one-sample shadow lag reaches every op’s inputs EQUALLY. No combine can see one input a sample staler than another at a clock edge, whatever the patch’s graph shape (the meta-turing-machine voice-2 blip class). A patch too small to fill a second core leaves core 1 empty; a patch no cut can fit still compiles, with the budget warning naming the overspend.
Core 1 also runs the TinyUSB stack. The role is read from the USB-C CC pins at
boot: a downstream device (a USB MIDI keyboard) selects host mode; a computer (or
an unrecognised board) selects device mode, which also runs the sysex parser. The
patch-swap handshake is lock-free by single-writer: core 1 stages an incoming
snapshot and sets a ready flag; core 0 reads it at the next sample boundary, calls
snapshot_apply, and swaps the runtime pointer.
Sysex transport
Frame: F0 7D 4C 45 <cmd> <8-into-7 payload> F7. The manufacturer id is
7D 4C 45 (educational id plus LE); payload bytes are 8-into-7 packed so
every byte stays below 0x80. Commands are defined in both cli/sysex.js and
runtime/sysex.h: write/read state, save to flash, factory reset, ping, and the
diagnostic and perf queries (diag, perf, slot-perf) with their matching dump
responses. The web editor uses the same framing layer as the CLI.
Tools
tools/ holds the build and codegen helpers: build_web.js bundles the
compiler and prelude for the web editor, the gen-*.js scripts regenerate the
pitch / rate / sine lookup tables baked into the runtime, and
attic/tools/cost.js does static per-slot cost estimation over the kernel cost
tables.
Build and flash
Firmware. Needs the Raspberry Pi
pico-sdk (set PICO_SDK_PATH):
cmake -B build
cmake --build build -j
cmake runs runtime/bake_factory.js first, which compiles
patches/meta-turing-machine.loupe into runtime/factory_snapshot.h (the
patch embedded in the firmware). The
build produces lens.uf2 and copies it to the repo root. Flash it by mounting
the card as the RPI-RP2 USB drive and copying lens.uf2 onto it.
LENS_PERF_PROBE (default off) is a cmake cache option that compiles in the
per-sample cycle probe behind the perf / slot-perf sysex queries; it halves
the audio torus to 64 KB, so release builds leave it off.
JS tooling.
npm install
node cli/cli.js write patches/turing-machine.loupe # compile -> snapshot -> WRITE_STATE
node cli/cli.js write ... --save # also flash it on the card
node cli/cli.js ping # handshake
node cli/cli.js watch patches/hello.loupe # live-code loop, re-push on save
node cli/cli.js perf # read perf counters
Adding a builtin
Three places:
- Add
(def name (fn (...) :kwargs (...)))toprelude.loupewith a docstring. - Wire the op to its kernel in
compiler/op-table.js(inputs and param0 fields). - Add the C kernel
op_nametoruntime/runtime.cand register it inKTABLE(andKFN).
Then run the validator (compiler/validate.js) to confirm the prelude,
op-table.js, and the C kernel table agree, and add fixture cases for the new
kernel.
Testing
The host runner (attic/test-runner/host_runner) runs a compiled snapshot against a
per-sample input trace. The gates compare the optimized walk against the
runtime_step_reference oracle bit for bit (attic/tests/oracle-diff.js) and
drive an impulse through the audio buffers (audio-gate.js). The gate scripts
live under attic/ (gitignored).
Hardware constraints
- RP2040 Cortex-M0+ overclocked to 250 MHz at 1.15 V. Roughly 5,200 cycles per sample at 48 kHz.
- No hardware divide, no 64-bit multiply, no FPU on M0+. All DSP is 32-bit
integer; use
umulhi32for high-half products. - 264 KB RAM, and it is tight (well above 90% used). The 128 KB audio pool
dominates and is the point. The audio path is flash-resident code pinned into
RAM via
__not_in_flash_func. Check the linker memory report after adding any static pool, and mark any new function on theProcessSample/runtime_stepcall tree with__not_in_flash_func. - 2 MB flash. A flash save blocks the audio loop for tens of milliseconds and reboots the card.
Gotchas
- IDE clang diagnostics about
ComputerCard.hnot being found are harmless; the real build iscmake --build build. vreg_set_voltageandset_sys_clock_khz(250000, true)must run beforeboard_init()inmain(). Do not reorder them.pico_enable_stdio_usb(lens 0)is required: enabling USB stdio breaks the ADC normalisation probe that detects whether a jack is patched.- The factory snapshot regenerates automatically under cmake. If you compile
outside cmake, run
node runtime/bake_factory.jsfrom the repo root.
Heritage
These attribution comments must survive every refactor:
- Drum voices after Mutable Instruments Plaits (Émilie Gillet).
- The band-limited sawtooth and the Utility-Pair lineage after Chris Johnson.
- The ADC self-heal / normalisation probe after Vincent Maurer’s Grains card,
preserved in
ComputerCard.h.
Where to start reading
- Compiler:
compiler/expander.jsfor how Loupe forms expand, thencompiler/lowerer.jsfor kernel selection, thencompiler/scheduler.jsfor the topological order and single-writer verifier. - Runtime:
runtime/runtime.cfor the per-sample walk and the kernels, thenruntime/snapshot_apply.cfor how a snapshot becomes a wired graph.