Help us shape the future of Go! Take the 2026 Go Developer Survey

let-go-undertone

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 15, 2026 License: MIT

README

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.

Directories

Path Synopsis
cmd
lghost command
lghost embeds let-go with the undertone binding linked in: lg is the control language, Go is the DSP engine.
lghost embeds let-go with the undertone binding linked in: lg is the control language, Go is the DSP engine.
undertone command
undertone: a tiny generative-ambience engine.
undertone: a tiny generative-ambience engine.
Package engine is undertone's generative-ambience engine: a stack-of-unit- opcodes synth VM, a tracker-style sequencer, and the game-state -> music parameter derivation, all deterministic from a seed.
Package engine is undertone's generative-ambience engine: a stack-of-unit- opcodes synth VM, a tracker-style sequencer, and the game-state -> music parameter derivation, all deterministic from a seed.
examples
descent/cli command
The descent demo's native front end embeds let-go and hands it the undertone engine through the audio surface sketch's v1 split: lg is the control language, Go is the DSP engine.
The descent demo's native front end embeds let-go and hands it the undertone engine through the audio surface sketch's v1 split: lg is the control language, Go is the DSP engine.
descent/lg
Package lg embeds the demo's lg sources so the browser front end (GOOS=js, no filesystem) can load them through core's load-string.
Package lg embeds the demo's lg sources so the browser front end (GOOS=js, no filesystem) can load them through core's load-string.
descent/web command
The descent demo's browser front end: the identical audio surface (let-go-undertone/letgo, driven by ../host), the identical lg lane — loaded from embedded sources via load-string, since GOOS=js has no filesystem — and the same scripted arc, with the trace going to the page instead of stdout.
The descent demo's browser front end: the identical audio surface (let-go-undertone/letgo, driven by ../host), the identical lg lane — loaded from embedded sources via load-string, since GOOS=js has no filesystem — and the same scripted arc, with the trace going to the page instead of stdout.
grid command
grid is a step sequencer over the undertone engine: a 16-step pattern of lead, bass, pulse and drum lanes, played by the engine's own tracker over its generated bed.
grid is a step sequencer over the undertone engine: a 16-step pattern of lead, bass, pulse and drum lanes, played by the engine's own tracker over its generated bed.
grid/lgmutate
Package lgmutate runs the grid example's pattern transforms, which are written in lg (mutate.lg), on an embedded let-go runtime.
Package lgmutate runs the grid example's pattern transforms, which are written in lg (mutate.lg), on an embedded let-go runtime.
Package letgo binds the undertone engine to let-go as the `audio` namespace: the music/one-shot state, the lg-facing natives, and the loader for the embedded lg sources.
Package letgo binds the undertone engine to let-go as the `audio` namespace: the music/one-shot state, the lg-facing natives, and the loader for the embedded lg sources.
lg
Package lg embeds the binding's lg sources so hosts without a filesystem — GOOS=js above all — can load them through core's load-string instead of the resolver's load path.
Package lg embeds the binding's lg sources so hosts without a filesystem — GOOS=js above all — can load them through core's load-string instead of the resolver's load path.
Package webaudio plays a PCM stream in the browser without oto.
Package webaudio plays a PCM stream in the browser without oto.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL