chirp · Rust · CPU only · one binary
Chirp
Chirp is a synthesizer and sequencer driven by text: a line of score, or a YAML document, is rendered to sound by a CPU engine written in Rust, from a two-note notification to a mixed and mastered song. It plays in a terminal and takes edits while it plays, drives MIDI gear on the same clock, serves agents over MCP, and runs in a browser tab, compiled to WebAssembly.
chirp @demo52 voices8.1 srendered in 569 ms-16.0 LUFSplan 38d4ca12Open in the app
1Write a sound
Chirp's score notation reads left to right, one token per event. Its one rule is that settings latch: an octave, a length, a velocity or any key=value applies to every later token until it changes, so a line says only what moves. Brackets scope changes, key= resets one setting and reset resets them all.
expr.pm=1a latched expression: palm mute on from hereF1/16@118F in octave 1, a sixteenth, velocity 118 for this noteF@66octave and length latched; softerF@74the next sixteenth.a rest of the latched lengthF2/8^phan eighth with a pinch harmonic, this note onlyA note is a name (C#4, Bb3), a MIDI number (m60) or a frequency (440hz). A chord is a symbol, C:maj7, voiced by the patch (close, open, drop-2 or real guitar fretboard shapes), or explicit pitches, C4,E4,G4. Lengths are note values (/8, /4. dotted, /12 triplet, /2+8 tied) or times (/150ms, /2b). Every command-line argument is one track, and track options bind to the next score and latch to later ones.
chirp 'C6/16 E G C7/8'4 voices1.8 srendered in 47 ms-16.0 LUFSplan ab2e9ccbOpen in the app
C6 and the sixteenth from /16, until C7/8 changes both.chirp -p guitar-8string -t 90 'E:m/2 C:maj G:maj D:maj/1'29 voices6.8 srendered in 277 ms-17.0 LUFSplan a193a278Open in the app
Drum kits map symbols to pieces: k kick, s snare, h hat, oh open hat, played together with commas (k,c). A kit latches sixteenth steps, so k . h . s . h . is half a bar of a groove. Any track can follow another's level: --duck kick is a sidechain, measured before the kick's fader, so even a muted trigger track can pump a pad.
chirp -t 120 -n kick -p kit '[k . . . k . . .]x4' --reset -p pad --duck kick 'C4:m7/1 Ab3:maj7/1'16 voices7.7 srendered in 366 ms-16.0 LUFSplan fb848f88Open in the app
The rest of the notation is for rhythm and variation. k(5,16)/16 spreads five hits evenly over sixteen steps, a Euclidean rhythm; tracks with different step lengths make polyrhythms. (C|Eb|Gb) picks one alternative each time it plays and F1/16?0.6 plays with probability 0.6; choices re-roll on every repeat and every loop pass, from the song's seed, so a loop varies without drifting. h/8^r=3 ratchets three hits into one note, s^flam adds a grace note, and %1 %b3 %5 are scale degrees in a latched key= and mode=.
chirp -t 104 -p kit '[k(5,16)/16]x4 ; [s(2,8,2)/8]x4 ; [h(7,12)/12]x4'56 voices9.3 srendered in 190 ms-17.3 LUFSplan 821af780Open in the app
2The song model
A score line is sugar. The general form is a song: YAML or JSON with tracks, buses, a master, inline patches, embedded songs, a tempo map and MIDI clock. A track plays a native patch, drives a MIDI device or embeds another song, and every kind sits on one sample-exact timeline. Saved songs compose: @done is a track or a score event, with the same transpose, stretch, envelope, effects and sends as any other, and with overrides such as @done tempo=150.
tempo: 112
buses: {verb: {fx: ["reverb mix=1 size=0.85"]}}
tracks:
- {name: drums, patch: kit, score: "[k . h . s . h . k k h . s . oh .]x2"}
- {name: pad, patch: pad, score: "C4:m7/1 Ab3:maj7/1", fx: ["duck by=drums depth=-9dB"]}
- {name: gtr, patch: guitar, score: "R/2 C:m/4 . Ab:maj/4", sends: {verb: -12dB}}
- {song: "done tempo=180", at: 2bar, transpose: 12, envelope: "a=1s r=1s"}
- {midi: {device: ableton, channel: 2, cc: {cutoff: {value: 0.2, mod: [{src: lfo.slow, amount: 0.6}]}}},
lfo: {slow: "sine rate=1/1"}, score: "C3:m7/1"}
A song with tempo: inherit is a part: it plays in the tempo of whatever embeds it, following tempo maps, so beats, riffs and layers combine freely, and tempo: x3/2 plays at a ratio of the host's tempo for metric modulation. A song's harmony: is a progression, in Roman numerals or chord symbols, and notes written as chord tones ($1 $3 $5 $7) follow whichever chord is sounding at their beat. One bass line then plays over any progression.
tempo: 86
harmony: "key=F2 mode=minor i/1 VI/1 III/1 VII7/1"
tracks:
- {name: beat, song: beat-dusty}
- {name: bass, song: harm-bass}
- {name: arp, song: harm-arp}
- {name: swell, song: harm-swell}161 voices12.6 srendered in 1312 ms-16.0 LUFSplan 83fa7a0cOpen in the app
i VI III VII7 in F minor. Only the progression is written here; the bass, arpeggio and swell each find their notes from it, which is why the roll follows the chords.Timing is part of the model. A tempo map is a list of {at, bpm, ramp, meter} points, and meter: 7/8 changes the meter from that point, so bar positions, bar-quantised live edits and the live view follow odd-meter sections. humanize is how a player feels a part: timing and velocity jitter, a drift that moves the player ahead of and behind the beat over bars rather than note by note, varied note lengths, and accents that follow the meter. A song's feel is the band's shared push and pull on top. All of it is off by default, and all of it is seeded, so the same song renders identically.
3From text to samples
Every input takes the same path. The command line, a score string, a song file and an agent's MCP arguments all become one spec, the model people and agents write. Resolve makes it self-contained. Lower compiles it into a plan, in which every voice's start, release and end frame is already known. Then the engine and the MIDI scheduler play the same plan.
Strict at the edge
The spec rejects anything it doesn't know, with a path to it, which is what lets an agent repair its own mistakes:
chirp plan --set tracks.0.gian=-3dB 'C4'
chirp: unknown field `gian`, expected one of `name`, `patch`, `midi`, `input`, `double`, `song`, `score`, `notes`, `at`, `repeat`, `transpose`, `stretch`, `vel`, `seed`, `params`, `gain`, `pan`, `envelope`, `lfo`, `auto`, `fx`, `sends`, `out`, `mute` at tracks[0].gian
chirp -p pad 'C3:m9/1 Ab2:mj7/1'
chirp: track `pad`: score token 2 `Ab2:mj7/1`: unknown chord quality `mj7` (known: maj m dim aug sus2 sus4 5 6 m6 7 maj7 m7 …)
Unit strings (-6dB, 2k, 120ms, 1/8, 2b, 1bar) and key-value strings ("reverb mix=0.3") are accepted wherever they make sense, so a song stays short without being loose.
Canonical and replayable
Resolve merges every patch from its extends chain and inlines it, kit pieces included, keyed by the reference text; it resolves embedded songs recursively and expands scores into explicit notes. chirp plan prints the result, and it replays to the same plan hash, which this page relies on: every example's one-liner and the song its Open in the app link carries were checked to plan identically before their audio was rendered.
Known before it sounds
Lowering knows every note before the first sample, so voices are allocated with exact lifetimes and poly, mono, legato and choke policies are applied up front. A cue resolves and lowers in 0.1 to 0.3 ms, and audio starts streaming while the rest renders, so a cue reaches the sound server 3 to 15 ms after the process starts.
4The engine
The engine is offline-first and pull-based. It renders 256-frame chunks, and within a chunk it renders the mixer graph level by level: each node's dependency depth (routing, sidechain keys and follow sources first) groups nodes that cannot affect each other. Those render in parallel on native builds and one after another in WebAssembly, with bit-identical output. Parameters update every 32 frames; gains and amplitude envelopes ramp every sample.
- Acyclic by construction. Tracks feed buses feed the master, plus sends and follow edges; the graph is sorted topologically, and a cycle, even a track following itself, is rejected with the names involved.
- Tails, not timeouts. After the last note the engine renders until the output stays below −60 dBFS for 50 ms, and a node whose input has been silent for longer than its effects' tail is skipped.
- Safe output. Non-finite samples are zeroed, the output is clamped, and denormals are flushed in feedback state without
unsafecode. The master runs the song's master effects, the configured volume and a lookahead limiter at −1 dBFS. - CPU only, on purpose. Initialising a GPU would cost more than rendering any cue. A two-second bell cue with reverb renders in about 10 ms of CPU; the eight-track demo renders at about 40× real time.
5Physical models
A patch is a synth or a kit. A synth stacks layers, with phase modulation between them, filters, envelopes, LFOs and drive. Oscillators, noise and additive layers are there, but the patches that sound like instruments are physical models: a string, a bore or a vocal tract is simulated, and the player's gestures (pick position, bow pressure, breath, vowel) are its parameters. That is why a held note keeps living and a hard pick sounds different from a soft one.
Plucked strings
The guitar is a DI signal from a Karplus–Strong string. The excitation is a pick's bright noise burst or, as soft rises, the smooth triangle of a finger, whose harmonics fall as 1/n². A comb at the pick position cancels harmonics: picking at 1/7 of the string removes the 7th harmonic, which is a quarter-tone flat of any scale note and is heard as a wrong pitch over a low riff. Four allpasses give wound strings their stretched partials, and their delay at the fundamental is subtracted from the loop, so a stiff string stays in tune. A fret-buzz clip, a choke that darkens the loop after release, pinch harmonics tiled at a harmonic node, and pickup combs with their electrical resonance (a humbucker near 2.5 kHz, a single coil at 4.5 to 7 kHz) complete it. Every stroke varies a little in pick position, brightness, a few cents and about a decibel, and hard picks start slightly sharp.
chirp -t 132 '@beat-djent ; @riff-chug7'303 voices8.8 srendered in 793 ms-16.0 LUFSplan 86655c0eOpen in the app
That riff is double-tracked: double: records a part twice, as two hard-panned tracks with their own seeds for timing and picking, and optionally a different rig on each side. They are genuinely two takes. A doubled riff measures an L/R correlation of about 0.2 between 400 Hz and 3.5 kHz with no widener at all.
Bowed strings
Bowed layers are a digital waveguide in the McIntyre–Schumacher–Woodhouse line: two delay lines meet at the bow, where a friction table decides whether the string sticks to the bow hair or slips. The built-ins are bowed inside the stable window, in Helmholtz motion, which a test pins: outside it the string slides into double slip, an octave up. The bridge's loss filter has its group delay taken off the loop, so the string is in tune within a cent, and three band-passes sized by body_size make the body. speak is how readily a stroke finds its tone, bite the extra pressure of a fresh stroke, and ^slur continues the bow instead of starting a new stroke.
chirp -p violin -t 84 'E5/4 F#5^slur G5/2^slur B4/4 D5^slur E5/2'6 voices7.0 srendered in 166 ms-16.0 LUFSplan 44c67039Open in the app
chirp -p tagelharpa -t 70 'D3,A3,D4/1 _ D3,A3,C4/2 D3,A3,E4/2'9 voices11.3 srendered in 393 ms-16.0 LUFSplan 8e737673Open in the app
Winds and voices
A wind layer is a bore driven by an exciter: a reed on a cylindrical bore (the clarinet's hollow odd harmonics), an air jet over an edge (the flute), buzzing lips (brass) or a reed on a conical bore (the saxophone, every harmonic and a honking low register). Pressure, breath noise and brightness are playable, and notes must stay in the exciter's range, because below it the oscillation stalls or jumps, as on the real instrument.
The voice layer is a source-filter singer: a glottal pulse whose shape follows effort, with breath, jitter and shimmer, through a cascade of five formant resonators on measured adult vowel targets, gliding between them over 25 ms. Consonants are written per note, as onset and coda codes, so A4^on=2^vow=0.75^off=4 sings "bed". The upper formants can cluster near 2.8 kHz, a trained singer's formant, which carries the voice's upper harmonics at sung pitches.
chirp -p tenor-sax -t 100 'D3/8 F G A/4 C4/8 A3 G/2 F/8 D/2.'9 voices6.2 srendered in 190 ms-16.0 LUFSplan bb96ff48Open in the app
chirp -p voice -t 84 'A3/4^on=2^vow=0.5 C4^on=4^vow=0.5 E4/2^on=2^vow=0.75^off=4'3 voices3.9 srendered in 90 ms-16.0 LUFSplan 60390ca9Open in the app
Struck things
Modal layers ring physical mode sets on rotating phasors: Bessel ratios for circular membranes, dense near-uniform sets for plates, cymbals and gongs, tuned bars for marimba, vibes and glockenspiel, and a church bell. The striker's hardness bounds the excitation's bandwidth, the strike position weights the modes, and plate-like sets add the chaotic wobble and wash of a real cymbal. Pianos, harps and vibes take a sustain pedal that lets notes ring to the next bar line.
chirp -p marimba -t 116 '[A3/16 C4 E A4]x2 [F3 A C4 F4]x2 [C4 E G C5]x2 [G3 B D4 G4]x2'32 voices6.5 srendered in 684 ms-16.0 LUFSplan 2847bf54Open in the app
6Effects, amps and the master
Every patch, track, bus and the master has an effect chain, and every parameter is a value or a value with modulation: per-note sources (env, vel, key, rand, expr) and per-channel ones (lfo, auto lanes, follow.TRACK, cc.N), in a short form such as cutoff=800+lfo.a*2.
- Dynamics compressor · gate · limiter · duck · glue · OTT · transient · autolevel · clip
- Tone filter · eq · drive · tape · crush · wah · formant · ring
- Space delay · reverb · chorus · phaser · tremolo · width · sympathetic
- Rigs amp · cab · ir
- Time stutter · freeze · tapestop · pitch
- Voices vocoder
Amps, cabinets and rooms
Saturators run 4× oversampled with first-order antiderivative anti-aliasing, primed so nothing spikes at start-up. amp models tube stages, a tone stack, power-amp sag and the bias shift of hard playing (bloom), voiced as modern, 5150, crunch, clean, Mark and others. cab models speaker cabinets with mic placement, and ir convolves a real cabinet impulse response by uniform partitioned FFT with 2.7 ms of latency, including speaker cone breakup and a stereo room mic.
The reverb is a modulated eight-line feedback-delay network: predelay, four diffusers, eight mutually prime delay lines with RT60 gains, damping and slow modulation, Householder feedback and early reflections, in room, chamber, hall, plate and spring characters. Its wet level is calibrated so mix behaves alike at any decay.
Loudness, measured
Renders report integrated loudness and true peak by ITU-R BS.1770 / EBU R128 (K-weighted, gated, 4× oversampled peak). --loudness -14 normalises without letting the true peak pass −1 dBTP, and with --limit masters up to the target through a soft clipper and a lookahead limiter, the way records get there. Live sessions keep momentary and short-term loudness, and an integrated figure from a constant-memory histogram, so a session can run for days. Every example on this page was normalised to −16 LUFS this way; the two that read −17 stopped at the −1 dBTP ceiling first.
Racks
A rack is a named, reusable chain with macros, rack:tape-echo space=0.8, written anywhere an effect list is. Racks nest and expand at resolve time, so canonical songs and plan hashes contain plain effects.
7Live sessions
Every audio playback is a live session with a control socket, and --loop makes it endless. The engine is the pure chirp-live crate (no threads, clocks or I/O), which is why the web build uses it unchanged. A session renders successive generations, each a fully lowered plan: a loop pass starts a new one sample-exactly at the previous loop end, with a fresh seed, while the old tails ring underneath. Edits are validated before they are accepted, so a bad one never interrupts the music.
chirp --loop --id groove -s groove-live &
chirp live set tracks.drums.score='k . s . k k s .' # next loop, track by name
chirp live set --when bar --xfade 2bar tempo=126 # a two-bar morph
chirp live var dark=0.8 wet=0.3 # variables: immediate, smoothed
chirp live mute drums ; chirp live solo bass # within ~10 ms
chirp live morph other-song --bars 4 # into another song from its top
chirp stop groove --at bar --fade 4s # a musical ending
- Variables and params. Songs declare variables, read as
var.NAMEby any modulation; patches declare params, which tracks bind to a number or any source, even another track's latest note. Params cannot bind params and variables are functions of song time, so dependency loops cannot be written. - A session is shared.
chirp live queueplays a song once at the next pass on a running session instead of starting a second stream, a playlist plays files that are re-read on their turn, and--exclusiveclaims a sound-server sink so any other Chirp stream on it, from any host, refuses and names the owner. - Hear the difference.
chirp live ab start old.yaml new.yaml --bars 4 --blindalternates two songs every few bars at the same song position; votes are tallied and logged when the comparison stops. - See it.
chirp watchdraws tracks as lanes scrolling past a fixed, latency-corrected playhead, with sections, the chord sounding now, loudness and a routing view;chirp playeradds the playlist, the library, a REPL and the agent around it. Both are clients of the socket, and--remote HOSTforwards a session over ssh.
8MIDI on the same clock
MIDI is a consumer, like a patch. A track with midi instead of patch sends the same notes, timing, voicings, strums and controller lanes to a device, and native and MIDI tracks mix on one timeline. Clock is a song property: 24 PPQN following the tempo map, with song position, Start and Stop. The scheduler is anchored to the audio stream's measured start, t0 = now + latency − written + offset_ms, so a DAW across the room and the patch in the next track line up.
On Linux the scheduler sleeps coarsely, then spins for a margin learned from how much the platform oversleeps, and sends at the deadline. On macOS each message carries its deadline as a host-clock timestamp and is handed to CoreMIDI 50 ms early, so the MIDI server, not a sleeping thread, delivers it on time. The host-clock FFI is the CLI's only unsafe code. Cancelling waits until everything handed over is due, then sends note-offs, All Notes Off and Stop.
9In the browser
The web app is the whole engine compiled to WebAssembly. The core crate needed no changes: its dependencies are pure Rust, with no threads, file system, clock or unsafe. It runs in a dedicated Worker that renders ahead of an AudioWorklet, and since GitHub Pages cannot send the headers that SharedArrayBuffer needs, audio crosses a MessagePort in transferred chunks instead.
The page offers the REPL with the CLI's verbs, a YAML editor that validates as you type and points at the failing path, a live view with lanes, sections, chords and per-track meters, a mix strip, WAV export at a loudness target, and a library that layers the built-ins, a published library and your own files in IndexedDB. A share link carries the song itself, compressed into the URL's fragment, and never reaches a server: every Open in the app link on this page is one.
10Agents in the loop
chirp mcp stdio exposes the CLI's own handlers to agents: chirp plays a score, a song or a library name; chirp_plan and chirp_render validate and render; the live_* tools steer a running session; presets and chirp_schema describe the library and the notation. The same validation that protects a person's edit protects an agent's.
Editing what's playing
The player and the web app each carry an agent that edits the playing song in conversation: "darker and slower", "add a hi-hat on sixteenths", "write a new 90 BPM groove in A minor". Both use one shared tool file. The compact tier offers a few tools whose enums are regenerated from the live library every turn, so names are always valid; a fuller tier takes path edits against the song's JSON Schema. The model makes one call at a time, and engine errors come back to it for repair.
Conversations persist. Editing an earlier message forks a branch, optionally restoring the song as it was then, and switching branches puts that branch's latest song on at the next bar. /loop keeps the agent going after every turn, and /goal keeps it working until it declares the goal complete.
Reproducing records
Agents also improve Chirp itself, by reproducing reference recordings. A climb takes one record, separates it into stems, transcribes a tab into a first Chirp song, and then improves it round by round. Each round renders the song, measures it against the record section by section and instrument by instrument, takes the biggest gap and fixes it in the most general place that closes it. The rule is that the song is the benchmark and the product is Chirp, so a gap should end up as a better default, a parameterised library patch or an engine feature, not a one-song hack. Several agents share a queue that spreads attention across songs and genres, each round is logged as a handoff to whoever climbs it next, and the operator listens in blind A/B comparisons, because the metric has blind spots and ears overrule it.
The rounds change Chirp under rules. A climb that found bowed strings settling into double slip, an octave up, is why the built-ins now sit inside the stable bowing window, pinned by a test. Doubles became two players when per-take intonation, not per-stroke noise, proved to be what decorrelates them. Kit panning became a default after it won across songs. Changing any default means re-scoring every registered climb before and after, genre-dependent fixes become parameters rather than defaults, and a new option at its neutral value must reproduce the old render bit for bit.
·Budgets
| Measure | Target | Measured |
|---|---|---|
| Process start to first audio, local sound server | < 50 ms | 3–15 ms |
| Resolve and lower a cue | < 1 ms | 0.1–0.3 ms |
| Two-second bell cue, full render with reverb | < 20 ms CPU | ~10 ms |
| Eight-track demo render | ≥ 20× real time | ~40× |
| MIDI lateness at the receiver, macOS background session | — | mean 0.05 ms, max 0.35 ms |
Measured on x86-64 under load, as recorded in Chirp's design notes. The render times beside each example were measured when this page was built, including loudness normalisation.