Everything is a pattern
A UI blip, a bassline, and an entire song are the same kind of object. Fire it once with play() for game SFX, or run it forever with loop() — held notes, grooves, and arrangements fall out of the same machinery.
A TypeScript synth engine built directly on the Web Audio API — one-shot sound effects, looped grooves, and whole multi-section songs, all from the same pattern. Zero required runtime dependencies. MIT licensed.
bun add @liminal-hq/undertone
# or: npm install @liminal-hq/undertoneA one-shot game sound effect and a looped, polyphonic track, from the same API:
import { note, rev, stack } from '@liminal-hq/undertone';
// One-shot SFX — call it from a click handler (autoplay policy):
note('c5').sound('triangle').decay(0.1).gain(0.5).play();
// Looped music — chords, a melody with a stereo shimmer, a euclidean bassline:
const handle = stack(
note('<[c3,e3,g3] [a2,c3,e3] [f2,a2,c3] [g2,b2,d3]>').sound('sine').sustain(0.8),
note('c5 [e5 g5] <b5 a5> ~').sound('triangle').every(2, rev).jux(rev).gain(0.4),
note('c2(3,8)').sound('square').lpf(400)
).loop({ bpm: 110 });
// ...later:
handle.stop();Every constructor returns an immutable Pattern; every chained call returns a new one. Nothing touches audio until .play() or .loop() — patterns are cheap to build, branch, and reuse.
Undertone's vocabulary is Strudel-flavoured, but it's a clean-room, MIT-licensed implementation built from the published TidalCycles papers, standard music theory, and Strudel's public documentation — never from its AGPL source or data. Ship it in anything.