let-go-undertone
A small generative-ambience engine for games, with a binding for
let-go. It is a demo-sized homage to
vsariola/sointu: there is no sointu code
here, only its shape. Instruments are stacks of unit opcodes run by a tiny
per-sample VM, a tracker-style sequencer drives them, and the whole track is
derived from a seed.
Try it in a browser: matt.parrett.us/let-go-undertone
(the grid, the
player, and a
let-go-driven descent).

The module has these parts:
| Package |
What it is |
engine |
The synth VM, the sequencer, a one-shot sfx voice pool, and the game-state → parameters derivation. Plain Go; imports nothing from let-go. |
letgo |
The let-go binding: an audio namespace that drives the engine from lg code, plus letgo/lg/ambience.lg, the same derivation written in lg. |
webaudio |
Browser output: an AudioWorklet fed in small slices from the page's event loop (see In the browser). |
cmd/undertone |
A CLI that plays live, renders WAVs, and draws a four-band VU meter. |
examples/ |
A step sequencer, the engine in a web page, and a let-go-driven game demo, each runnable in a browser. |
cmd/lghost is a minimal lg runner with the binding linked in, for trying the
namespace without lgx.
The engine
Music parameters come from game state:
- mood:
dungeon (minor ladder, saw drone, fifth), overworld
(mixolydian → major → lydian with elevation, sine bed, lifted bells),
meadow (major, high and airy: near-silent drone, dense lifted bells),
aurora (slow lydian veils through the delay, no drone, the odd chime) or
village (the first with drums: a swung kick, clap and shaker under
kalimba and pluck, building to a festival with level).
Moods are rows in a table (engine/mood.go, mirrored in
letgo/lg/ambience.lg), so adding one is an entry in each
- level: depth darkens, slows and thickens; elevation brightens
- tension (0..1): raises tempo, fades in a low pulse, slides the second
drone from fifth to tritone to minor ninth, and thins the calm layers
- seed: picks from hand-tuned option tables, including the lead
instrument (bell, chime, clang, kalimba, reed, or a Karplus-Strong pluck)
Synth.SetParameters stages a new parameter set and applies it at the next
pattern boundary, so a change mid-playback is a seamless transition. Output is
44.1 kHz 16-bit stereo through a cross-fed delay bus and a tanh soft clip.
Out-of-range parameters are clamped rather than trusted, since they often come
from lg code or a page.
The sequencer is a 16-row tracker underneath. Synth.SetGrid hands it a
written pattern (lead and bass lanes of scale degrees, a pulse lane, and
kick, clap and shaker lanes of sixteenths in groove notation) to play in place
of the generated one, optionally over the generated drone, pad and air;
SeedGrid derives a starting pattern from a seed, writing out the mood's
groove if it has one. The
grid example is a step sequencer built on it.
One-shots run beside the sequencer in an 8-voice pool that the sequencer never
sees, so a trigger cannot change the music's random streams. Patches:
footstep, hit, thud, creak, cry, sink, lift, thunk, snap,
twang, pluck. Triggers take a gain, a pan, a sample-accurate delay, and
either a MIDI note or a scale degree resolved against the current parameters,
so a pickup chime lands in the music's key.
Determinism
The same parameters render byte-identical audio on one CPU architecture.
The derivation itself is pinned across architectures (its products go through
explicit float64 conversions, so Go cannot fuse them into FMA on arm64). The
per-sample VM and the pattern builder still contain unfused products, so a
render on arm64 and one on amd64 can differ in the last bit. Compare renders on
the same architecture.
The CLI
go install github.com/mparrett/let-go-undertone/cmd/undertone@latest
undertone -play # live, endless
undertone -play -depth 5 -tension 0.8 # deeper and more dangerous
undertone -play -mood overworld -depth 3 # -depth -3 is the same thing
undertone -play -vu compact # meters: bars | compact | tall
undertone -wav out.wav -depth 4 -lead pluck -dur 30
The let-go binding
Importing github.com/mparrett/let-go-undertone/letgo registers the audio
namespace from the package's init, so lg code can call it with no generated
interop:
| native |
|
audio/set-parameters |
one map, merged over the current state, applied at the next pattern boundary; unknown keys are ignored |
audio/music-start, audio/music-stop |
playback |
audio/trigger |
a trigger map ({:shot :hit :gain 0.8 :pan 0.3}) or a vector of them; fire-and-forget |
audio/render-wav |
(audio/render-wav "out.wav" 20.0): offline render, for checking determinism without a sound card |
The surface is write-only: nothing reads audio state back into lg, so game
logic cannot branch on whether a sound played and replays stay deterministic.
Triggers sent before the music starts are dropped silently.
The natives act on a default letgo.Music. A Go host that wants to own the
instance creates one with letgo.NewMusic and calls letgo.SetMusic before
running lg code. Hosts with no filesystem (GOOS=js) can load the embedded
ambience namespace with letgo.LoadEmbedded.
With lgx
Link the binding into a built lg runtime as a Go dep. It registers itself,
so it needs :go/version and no :go/interop:
{:lg-runtime :built
:lg-version "1.13.0"
:deps {github.com/mparrett/let-go-undertone/letgo {:go/version "main"}}}
:go/version takes anything Go accepts: a tag, a commit, a branch. Then, from
lg:
(audio/set-parameters {:mood :dungeon :bpm 54.0 :root 38 :drone-gain 0.5})
(audio/music-start)
(audio/trigger {:shot :footstep :gain 0.3 :pan 0.4})
The ambience namespace that derives full parameter maps from
(seed, depth, tension) is at letgo/lg/ambience.lg. Put that directory on
your load path, or embed it as above.
Without lgx
go run ./cmd/lghost -e '(do (ambience/score! 7 4 0.6) (audio/music-start) (sleep 60000))'
go run ./cmd/lghost -e '(do (ambience/score! 7 4 0.6) (audio/render-wav "out.wav" 20.0))'
letgo/parity_test.go renders every mood across a grid of seeds, depths and tensions
through both derivations, Go and lg, and compares the parameters field by
field plus a hash of the PCM. Two things it caught are worth knowing if you
port similar code to lg. First, lg's * raises on overflow, so seed mixing
needs unchecked-multiply. Second, lg has no unsigned integer, so an unsigned
remainder needs doing by hand (ambience/umod).
A full lg CLI with audio
A program that already runs on the stock lg CLI, with its flags (-source-paths,
-storage-id, …), can keep them all by building that CLI with the binding linked
in. The whole main.go is:
package main
import (
"os"
"github.com/nooga/let-go/pkg/cli"
"github.com/mparrett/let-go-undertone/letgo"
)
func main() {
_ = letgo.CurrentMusic() // the import registers the audio namespace
os.Exit(cli.Main("dev", "undertone"))
}
Run the program with this binary in place of lg. Put letgo/lg on
-source-paths if it uses ambience. To keep one codebase that also runs on a
stock lg, where there is no audio namespace, look the natives up at runtime:
(resolve 'audio/trigger) is nil there.
Native playback: keep the player reachable
Natively, playback goes through oto. oto attaches a cleanup to every *oto.Player that closes it once the handle
is unreachable, and its internal mixer does not keep the handle alive
(ebitengine/oto#293). A player
held only in a local stops at the next GC, silently, with no error. Store the
result of NewPlayer in a struct field or a package-level variable, or call
runtime.KeepAlive on it for as long as it plays. letgo.Music keeps its
player in a field. cmd/undertone/playerlifetime_test.go parses every
NewPlayer call site in the module and fails on one that does neither.
Native latency
engine.DeviceBuffer and engine.PlayerBuffer are what letgo.Music opens
oto with, in place of its streaming-oriented defaults. On the measuring bench,
tap-to-sound dropped from a median of about 820 ms to about 130 ms. The CLI
keeps oto's deeper defaults, since a music bed has no one-shots waiting
behind the read-ahead.
In the browser
letgo.Music and cmd/undertone play through the webaudio package under
GOOS=js, not oto. oto's browser backend renders a whole buffer inside the
audio thread's refill deadline, and Go's wasm has one thread with no
preemption, so on a phone the burst overran the deadline and crackled at every
buffer size tried. webaudio decouples the two: an AudioWorklet only copies
out of a ring, and a goroutine renders 512-frame slices within a 3 ms budget
until about 300 ms is queued, then yields to the page. Where the page is
cross-origin isolated the ring is a SharedArrayBuffer; elsewhere slices travel
as transferred messages. Both played without a dropout on an iPhone, including
under 120 ms main-thread stalls.
Two things the page has to provide: HTTPS (or localhost), because Safari
offers AudioWorklet only in a secure context; and a tap, because browsers
start audio only from a user gesture. webaudio.Open listens for that tap
itself.
let-go itself may grow a host audio seam; see
nooga/let-go#255.
Building
Builds need no cgo. CGO_ENABLED=0 works for macOS, for Linux (PulseAudio
through a pure-Go client, or ALSA loaded through purego), and for
GOOS=js GOARCH=wasm. oto is pinned at v3.5.1 and is used only natively.
The let-go binding avoids reflection-boxed natives, so it also runs under
TinyGo.
History
Extracted on 2026-10-09 from a private workspace, where it began as
sointu-homage on 2026-08-14. The commit history is preserved.
Thanks
To jasalt, whose
govertone, an experiment pairing Sointu
with let-go shared in the Clojurians Slack #let-go channel in July 2026,
introduced Sointu to this project and prompted it. And to
vsariola for Sointu itself.
License
MIT; see LICENSE.