A progressive introduction to coding music as music: from your first scheduled note to the full two-layer model, offline sound-forging, and the workflow around it. No prior YAWN knowledge assumed; no prior Lua assumed either — everything you need is explained as it appears.
This tutorial is tested. Every
Luasnippet marked run in this file is executed headlessly by the suite (tests/test_TutorialLiveCode.cpp), exactly like the bundled demo songs inassets/examples/livecode/: the snippet runs as a real generation on a real default project, the transport is pumped so scheduled callbacks fire, the snippet is re-run (the Ctrl+L edit loop), and the test fails if anything errors or even prints a warning to the~console. If this document says it works, it works.The reference for every detail (design, scheduling model, full API surface) is live-coding.md. This tutorial is the staged path through it.
You will need
- A running YAWN (docs/building.md or a release download)
- Fresh default project (File → New — two empty tracks, two scenes)
- Ctrl+L and Ctrl+Shift+L on your keyboard, and the
~(backtick) key
Read this once — the three facts that make everything else click:
| Fact | Meaning |
|---|---|
| Tracks count from 0, scenes count from 1 | yawn.note(0, …) plays track 1; clips = { [1] = … } is scene 1. This is the single most common source of confusion. |
| Velocities differ by API | yawn.note takes MIDI velocity 0..127. Clip notes (yawn.set_notes, song clips, yawn.render) take 0.0..1.0. |
| Two layers, one file | song is the declarative project (the DAW converges to it). improv is the performer (callbacks fired on the musical grid). Until Lesson 5, you'll mostly live in the second. |
Also worth knowing up front:
- the audio thread never runs your code. Lua runs on the UI thread; sounds and edits are handed to the audio engine through the same paths the UI itself uses. You cannot crash the audio stream from a script — the worst a bad line can do is print an error.
- pitches can be typed by hand.
yawn.notetakes a number (0..127) or a note name —"C2","F#2","Bb3"— where MIDI 60 = C4, matching YAWN everywhere else. And long patterns write better as text:yawn.midi("C2_8 C2_8 G2_4")andyawn.drums{ BD = "x-o-" }turn readable strings into notes (Lesson 4). Everything in the device catalog — instruments, effects, every parameter — lives in the appendix table at the end of this document.
Live code runs from a script file. Press Ctrl+L on a fresh project:
YAWN creates a commented template the first time (for an unsaved project
it lives at ~/.yawn/livecode/main.lua; for a saved project, inside the
project's livecode/ folder) and tells you to edit it. Press Ctrl+L again
(or ~ → Edit tab → Run to run straight from the editor) and it executes.
Replace the template with this and run:
-- Lesson 1 — a heartbeat
song = {
tracks = {
{ uid = 1, name = "Kick", type = "midi",
instrument = { id = "subsynth", params = {
["Osc1 Level"] = 1.0, ["Sub Level"] = 0.6,
["Filter Cutoff"] = 0.2, ["Filter Env"] = 0.8,
["Amp Decay"] = 0.2 } } },
},
}
yawn.set_playing(true) -- improv callbacks ride transport time
improv.every(1, function(beat) -- every beat, from now on
yawn.note(0, "C2", 127, 0.3, 0, beat) -- track 1 (0-based), C2, loudest
end)
-- @assert tracks:2
-- @assert playing
-- @assert inst:0:subsynthYou should hear a kick drum on every beat. What just happened, line by line:
songdeclared one track. A fresh project starts with two default tracks;uid = 1adopts the first one (renames it "Kick", flips it to a MIDI track because you asked fortype = "midi", and attaches a SubSynth (subsynth) from the device factory). Track indices don't move: the adopted track is still track 0.yawn.set_playing(true)starts the transport. The improv layer is transport-anchored — no play, no callbacks.improv.every(1, fn)— every 1 beat, first fire one beat from now (every(0.5, …)would be eighths,every(0.25, …)sixteenths).yawn.note(track, pitch, vel, dur, ch, at_beat)— the payload primitive. Here: track 1 (0-based), pitch"C2"(a note name — a MIDI number works identically), velocity 127, gate 0.3 beats, channel 0, on transport beatbeat.
That last argument is the important one. The scheduler fires callbacks
slightly before their target moment (lookahead — more in Lesson 2) and
passes the upcoming target beat as beat. Passing it to yawn.note lets
the audio engine place the note sample-accurately inside the right block
instead of "somewhere this frame". Rule of thumb: when a callback is fed
a beat, feed that beat to the note.
Try this now, with the transport still playing:
- Change
"C2"to"G2"— Run again (Ctrl+L, or Ctrl+Enter in the Edit tab to run the editor buffer). Each run replaces the previous generation at the next bar boundary, so the change lands musically, mid-groove, with no silence. A mistyped name is an immediate console error naming the token (bad pitch 'Hc4'…) — nothing plays, nothing silently dies. - Change the rhythm:
improv.every(1, …)→improv.every(0.25, …). - Watch the console: each run logs
live code: generation N running. The generation number in the~console header tells you which layer is live right now.
One safety property to internalize before moving on: a failed run does not stop the show. Introduce a deliberate typo and run — the previous generation keeps performing while the console prints the Lua error with line information. (An entry that errors three times in a row is auto-cancelled, so one broken callback can't spam the log forever.)
improv.every is for things that repeat on a fixed subdivision: hats,
pulses, ticks. Musical structure — where the downbeat goes, how the bass
moves bar-to-bar — wants a different shape: plan once per bar, fire many
notes at exact beats. The scheduler has four primitives:
| Primitive | Fires | Receives |
|---|---|---|
improv.every(beats, fn, [reps]) |
every beats, starting one interval from now |
the upcoming target beat |
improv.on_bar(fn) |
on every bar boundary (grid origin at beat 0) | the 1-based bar number |
improv.after(deltaBeats, fn) |
once, deltaBeats from now |
the target beat |
improv.at(bar, sub, fn) or improv.at(beat, fn) |
once, at an absolute position | the target beat |
A full groove — the same drum kit as Lesson 1 plus a bass — where each voice plays the way that voice wants to be written:
-- Lesson 2 — a groove
song = {
bpm = 122,
tracks = {
{ uid = 1, name = "Kick", type = "midi",
instrument = { id = "subsynth", params = {
["Osc1 Level"] = 1.0, ["Sub Level"] = 0.6,
["Filter Cutoff"] = 0.18, ["Filter Env"] = 0.9,
["Amp Decay"] = 0.18 } } },
{ uid = 2, name = "Hats", type = "midi", volume = 0.55,
instrument = { id = "subsynth", params = {
["Osc1 Wave"] = 4, ["Noise Level"] = 1.0,
["Osc1 Level"] = 0.0, ["Filter Cutoff"] = 0.85,
["Filter Type"] = 2, ["Filter Reso"] = 0.15,
["Amp Attack"] = 0.001, ["Amp Decay"] = 0.045 } } },
{ name = "Bass", type = "midi", volume = 0.8,
instrument = { id = "subsynth", params = {
["Osc1 Wave"] = 2, ["Osc1 Level"] = 0.9,
["Sub Level"] = 0.5, ["Filter Cutoff"] = 0.24,
["Filter Type"] = 0, ["Filter Env"] = 0.55,
["Amp Attack"] = 0.002, ["Amp Decay"] = 0.5 } } },
},
}
-- Kick: where THIS groove lives is a decision, not a tick — plan it per bar.
improv.on_bar(function(bar)
local b = (bar - 1) * 4 -- bar 1 spans beats 0..4
yawn.note(0, "C2", 127, 0.3, 0, b) -- downbeat, loud
yawn.note(0, "C2", 102, 0.15, 0, b + 2.5) -- pickup off the grid
end)
-- Hats: nobody formats a hat on a spreadsheet — let the grid carry it.
improv.every(0.5, function(beat)
local open = (beat % 2 == 0.5) -- every second eighth opens
yawn.note(1, open and "A#2" or "F#2", open and 92 or 56, 0.05, 0, beat)
end)
-- Bass: the code transposes, so the roots are numbers here (A1, C2, F2, G2)
-- and +7 stacks a fifth on top. Note names are sugar, not variables.
local roots = { 33, 36, 41, 43 }
improv.on_bar(function(bar)
local root = roots[((bar - 1) % #roots) + 1]
local b = (bar - 1) * 4
yawn.note(2, root, 100, 0.45, 0, b)
yawn.note(2, root + 7, 95, 0.30, 0, b + 2)
end)
-- One-shots: a fill lands exactly once, at 12.5 beats in:
improv.after(12.5, function(beat)
yawn.note(1, "A#2", 80, 0.06, 0, beat)
yawn.log("fill landed on beat " .. beat)
end)
-- And an absolute appointment: bar 3, sub-beat 1.5:
improv.at(3, 1.5, function(beat)
yawn.note(0, "C2", 70, 0.1, 0, beat) -- a ghost kick
end)
yawn.set_playing(true)
-- @assert bpm:122
-- @assert tracks:3
-- @assert playing
-- @assert inst:0:subsynthDetail worth understanding — what happens if a beat is missed. The scheduler runs on the UI thread; if the machine hitches longer than the schedule-ahead window, a note can reach the audio engine after its beat. The default is graceful: play it now. A strict performer prefers silence over bad timing:
improv.late_policy("drop") -- "play" (default) or "drop" + a console note
improv.lookahead(0.15) -- widen the schedule-ahead window (default 0.10 s)Both stay cheap to change live. Lookahead is converted to beats at the current tempo every frame, so a queued note measures musical distance, not wall time: drag the BPM and the plan stretches with it instead of piling up.
Two footnotes for completeness: improv.every(interval, fn, reps) — with
reps set, the pattern stops after that many fires (reps unset or 0 =
forever). improv.clear(handle) + improv.clear_all() un-schedule things
(every/at/… return a handle). In practice you rarely need either
because a re-run replaces the whole layer — that's the design.
Live coding is the art of changing one thing and keeping everything else.
The yawn.state table exists so your parameters live outside the
callback code: assignments to it persist across runs (the Lua state itself
survives Ctrl+L), and it is even harvested across a full reload
(Ctrl+Shift+L, which recreates the Lua state and migrates yawn.state
field-by-field).
One idiom matters: field-level fallbacks:
-- Lesson 3 — one knob, many hats
song = {
tracks = {
{ uid = 1, name = "Kick", type = "midi",
instrument = { id = "subsynth", params = {
["Osc1 Level"] = 1.0, ["Sub Level"] = 0.6,
["Filter Cutoff"] = 0.2, ["Filter Env"] = 0.8,
["Amp Decay"] = 0.2 } } },
{ uid = 2, name = "Hats", type = "midi", volume = 0.6,
instrument = { id = "subsynth", params = {
["Osc1 Wave"] = 4, ["Noise Level"] = 1.0,
["Osc1 Level"] = 0.0, ["Filter Cutoff"] = 0.85,
["Filter Type"] = 2, ["Amp Attack"] = 0.001,
["Amp Decay"] = 0.045 } } },
},
}
-- yawn.state is pre-seeded as a (truthy, empty) table by the engine, so
-- the *table-level* idiom `yawn.state = yawn.state or {}` is a no-op —
-- always fall back per FIELD:
yawn.state.density = yawn.state.density or 0.5
improv.lookahead(0.15)
improv.every(1, function(beat) -- the kick keeps the time
yawn.note(0, "C2", 127, 0.3, 0, beat)
end)
improv.every(0.5, function(beat) -- the hats take the weather
local open = (beat % 2 == 0.5)
if open or math.random() < yawn.state.density then
yawn.note(1, open and "A#2" or "F#2",
open and 95 or math.floor(50 + 40 * math.random()),
0.05, 0, beat)
end
end)
improv.on_bar(function(bar) -- and the system breathes
if bar % 8 == 0 then
yawn.state.density = 0.4 + 0.5 * math.random()
yawn.toast(("evolution: density → %.2f"):format(yawn.state.density))
end
end)
yawn.set_playing(true)
-- @assert tracks:2
-- @assert playing
-- @assert inst:1:subsynthNow, with it running, perform some states:
- Open the
~console's Edit tab, typeyawn.state.density = 0.95anywhere in the script and hit Run (Ctrl+Enter). The pattern mutates on the spot — no stop, no restart. - Press Ctrl+Shift+L: the script re-loads from disk and carries
yawn.state.densityacross the reload. The hats don't change character because of a reload — that's the point. - Press Ctrl+L once to stop: scheduled callbacks are dropped, already triggered notes are released, and the Lua state keeps its values. Press it again: you're back on, mid-tune, with the same density.
The console also shows the gen counter and the pending-schedule count;
the Reload, Stop, Run and Clear buttons up top do exactly what the
keys do. The 256-line console ring is your timeline of what happened.
Everything so far performed live without leaving a trace in the project. Clips are the durable form — and they can be authored from code, including with two text notations that turn patterns into single lines:
yawn.midi(phrase)— melodic notation:pitch_octave_duration.C4_8is an eighth-note C4 (MIDI 60 = C4, matching the rest of YAWN); durations_1 _2 _4 _8 _16 _32are whole-note fractions (4 / 2 / 1 / 0.5 / 0.25 / 0.125 beats); each note begins where the previous ends.*Nmultiplies a duration (_16*16= exactly one bar of 16ths),@velsets velocity 0..1 (default 0.8),Ris a rest,[C3E3G3]_2is a chord,|is a cosmetic bar line, and!patternarpeggiates a chord (up,down,updown,downup,random,asplayed, optional octave count:[C3E3G3]_16*8!up2sweeps two octaves).yawn.drums{lanes}— a step grid, one character per 16th step, one lane per voice on the GM drum channel:Xaccent (0.88),xnormal (0.76),osoft (0.6),gghost (0.31),./-rest. Lanes may have different lengths (each loops; the clip is the longest lane, or{beats=N}forces it). Lane names:kick/bd,snare/sn,clap/cp,rim/rs,hihat/hh,openhh/oh,ride/rd,crash/cr,hightom/ht,tommid/tm,lowtom/lt,floortom/ft(case-insensitive).
Both return a clip spec { beats = …, notes = … } — the same table the
song layer's clips accept, so notation composes everywhere. Put it to
work:
-- Lesson 4 — clips from code
song = {
tracks = {
{ uid = 1, name = "Drums", type = "midi", volume = 0.9,
instrument = { id = "drumrack" },
clips = { [1] = yawn.drums{ -- the kit, as a grid
BD = "x---x---x---x---",
SN = "----x-------x---",
HH = "x-x-x-x-x-x-x-x-",
} } },
{ uid = 2, name = "Bass", type = "midi", volume = 0.8,
instrument = { id = "subsynth", params = {
["Osc1 Wave"] = 2, ["Osc1 Level"] = 0.9, ["Sub Level"] = 0.5,
["Filter Cutoff"] = 0.24, ["Filter Env"] = 0.55,
["Amp Decay"] = 0.5 } },
clips = {
[1] = yawn.midi("C2_8 C2_8 D#2_8*2 G2_4"), -- exactly 3 beats
} },
{ name = "Keys", type = "midi", volume = 0.6,
instrument = { id = "karplus", params = { ["Damping"] = 0.45 } },
clips = {
[1] = yawn.midi("[C4E4G4]_16*16!up@0.45"), -- a typed arp
} },
},
}
-- set_notes is the live-authoring tool — and it eats clip specs whole:
-- here, a scene-2 bass variation written as ONE readable line, without
-- touching scene 1. (Fresh pads on a new Drum Rack are empty until you
-- load samples into them — drag some from the browser now, or forge them
-- in Lesson 6. The grid keeps firing at the pads once they're loaded.)
yawn.set_notes(1, 2, yawn.midi("A1_8@0.9 C2_8@0.7 F2_8@0.85 C2_8@0.65"))
yawn.launch_scene(1) -- the whole scene, quantized to the downbeat
-- @assert tracks:3
-- @assert playing
-- @assert inst:0:drumrack
-- @assert midinotes:0:1:14
-- @assert midinotes:1:1:4
-- @assert midinotes:2:1:16
-- @assert midinotes:1:2:4(tracks here counts the two adopted default tracks plus the one created
Keys track — see Lesson 5 for the ownership rules behind that.)
yawn.launch_scene(1) is the self-start idiom: it defers the launch until
after the song apply, seeks to the downbeat (a stopped transport starts
immediately, in lockstep; a running one quantizes to the next bar), and
starts the transport. RUN becomes music, not "and then you have to press
play".
Clips have a full authoring API beyond the notation:
yawn.set_notes(track, scene, notes, [opts]) -- install/replace a clip's
-- content: {start, dur, pitch,
-- vel(0..1), [ch]} tuples
-- (positional or named),
-- OR a yawn.midi("...") /
-- yawn.drums{...} spec
yawn.get_notes(track, scene) -> normalized note table (or nil)
yawn.new_midi_clip(track, scene, [beats=4]) -- empty clip
yawn.clear_notes(track, scene) -- empty it, keep the length
yawn.launch_clip(track, scene, { quantize = "none"|"beat"|"bar" })set_notes derives its length from the spec's beats when one is given,
else keeps whatever's in the slot (or 4 beats); an options table
overrides everything — for instance yawn.set_notes(0, 1, notes, { beats = 4 }).
The notation also has hardware support: the ~ console's Edit tab has a
notation keyboard strip (one octave of piano keys, _N length
buttons, a ×N multiplier, and a MIDI toggle that arms your physical
keyboard so played notes type themselves into the editor as tokens).
So far the song block was scaffolding. It is the actual feature: a
declarative, diff-applied description of the project. On every run the
engine parses song, diffs it against the live project, and applies the
minimal edit list through the same edit primitives the UI uses
(instrument swaps, effect chains, mixer commands, clip installs). Two
consequences follow, and both matter daily:
- Re-running unchanged code is a no-op. Nothing duplicates, nothing is re-created. You can hammer Ctrl+L.
- The script is the source of truth for what it declares. Rename or
delete a script-owned track in the UI, and the next run re-asserts or
removes it (to delete something permanently, delete it from
song).
The contract on each field is presence = ownership: a key you declare
is managed by the diff (and re-converged); a key you omit is left alone.
That's why you can write a minimal script on top of a manually-set-up
project, and why instrument = { id = "drumrack" } with no params key
installs the device and then never touches its knobs.
-- Lesson 5 — a whole sketch, as data
song = {
bpm = 116,
scenes = 3, -- grow-only: never trims your scenes
tracks = {
{ uid = 1, name = "Kick", type = "midi", volume = 1.0,
instrument = { id = "subsynth", params = {
["Osc1 Level"] = 1.0, ["Sub Level"] = 0.6,
["Filter Cutoff"] = 0.2, ["Filter Env"] = 0.85,
["Amp Decay"] = 0.2 } } },
{ uid = 2, name = "Keys", type = "midi", volume = 0.5,
pan = -0.25,
instrument = { id = "electricpiano", params = { ["Bell"] = 0.3 } },
fx = { { id = "chorus", params = { ["Wet/Dry"] = 0.2 } },
{ id = "delay",
params = { ["Wet/Dry"] = 0.2, ["Feedback"] = 0.3 } } },
clips = {
[1] = { beats = 4, notes = {
{ 0.0, 1.5, 53, 0.5 }, { 0.0, 1.5, 60, 0.45 },
{ 1.0, 1.2, 57, 0.35 },
{ 2.0, 1.5, 55, 0.5 }, { 2.0, 1.5, 62, 0.45 },
{ 3.0, 1.2, 59, 0.35 } } },
[2] = { beats = 4, notes = {
{ 0.0, 1.5, 50, 0.5 }, { 0.0, 1.5, 57, 0.45 },
{ 2.0, 1.5, 52, 0.5 }, { 2.0, 1.5, 59, 0.45 } } } } },
{ name = "Pluck", type = "midi", volume = 0.55, pan = 0.25,
instrument = { id = "karplus", params = { ["Damping"] = 0.4 } },
fx = { { id = "plate", params = { ["Wet/Dry"] = 0.25 } } },
clips = {
[2] = { beats = 4, notes = {
{ 0.0, 0.5, 84, 0.7 }, { 0.75, 0.25, 89, 0.55 },
{ 2.0, 0.5, 82, 0.6 }, { 3.25, 0.5, 80, 0.5 } } } } },
{ name = "Kick line", type = "midi", volume = 1.0,
clips = { [1] = { beats = 4, notes = {
{ 0.0, 0.25, 36, 1.0 }, { 2.5, 0.25, 36, 0.8 } } } } },
},
}
yawn.set_playing(true)
improv.every(1, function(beat) -- a pulse on the Keys track, under
yawn.note(1, 41, 40, 0.2, 0, beat) -- everything, from code
end)
-- @assert bpm:116
-- @assert tracks:4
-- @assert playing
-- @assert inst:1:electricpiano
-- @assert midinotes:1:1:6
-- @assert midinotes:1:2:4
-- @assert midinotes:2:2:4
-- @assert midinotes:3:1:2(case check: the instrument-less "Kick line" is silent until an instrument lands on it — the sketch's point is the data shape, not the mix.)
Notes inside clips are {start, dur, pitch, vel, [ch]} tuples in beats
(velocities 0..1) — positional, or named ({ start = 0, dur = 0.5, pitch = 36, vel = 1.0, ch = 0 }). Devices are factory ids (subsynth,
fmsynth, karplus, wavetable, sampler, granular, drumrack,
drumsynth, drumslop, vocoder, drawbarorgan, electricpiano …;
effects reverb, plate, eq, compressor, chorus, delay,
filter, distortion, phaser … — the browser's device list is the
same catalog). Params by name or index both work:
["Filter Cutoff"] = 0.2 or [8] = 0.2 — names are matched
case-insensitively at apply time, and a typo is a warning listing the
device's actual parameter names, not a mystery (the drum-kit examples
above lean on that check).
Editing flow, concretely: open ~ → Code tab. It shows the song
block regenerated from the live project — your UI hand-edits flow back
into readable code. Click Sync and the tool patches the script's own
song block in place (comments survive byte-for-byte) so the file the
next run reads is the project you played with. This is round-trip on
purpose: tweak a cutoff on-screen, watch those param names turn into a
certain line of Lua.
Sometimes you want a sound as a file: a snapshot of an instrument
playing your notes, with your FX on it, ready to load into a sampler pad
or clip. yawn.render does exactly that — on a worker thread, offline,
never touching the audio stream — and delivers the buffer wherever a
target says:
-- Lesson 6 — the sample forge
song = {
bpm = 116,
tracks = {
{ uid = 1, name = "Kit", type = "midi",
instrument = { id = "drumrack" } },
{ uid = 2, name = "Sampler", type = "midi",
instrument = { id = "sampler" } },
},
}
local jobKick = yawn.render{
device = "subsynth", -- any Factory instrument id
params = { ["Osc1 Level"] = 1.0, ["Sub Level"] = 0.5,
["Filter Env"] = 0.9, ["Amp Decay"] = 0.4 },
notes = yawn.midi("C2_16*2@1"), -- the input notes, typed
beats = 1.0, -- rendered length (at 116 BPM)
tail = 1.5, -- +release tail on top
target = { kind = "drumrack", track = 0, pad = 60, name = "logo kick" },
}
local jobZap = yawn.render{
device = "karplus", params = { Damping = 0.2 },
notes = yawn.midi("C4_8@0.9"),
beats = 0.5, tail = 1.0,
target = { kind = "sampler", track = 1, name = "zap" },
}
yawn.log("forge jobs " .. jobKick .. " and " .. jobZap .. " rendering...")
-- @assert tracks:2
-- @assert inst:0:drumrack
-- @assert inst:1:sampler
-- @assert delivered:2The console reports back when each job lands
(prerender: delivered 'logo kick' (N frames → drumrack)), and the kit's
pad 60 / the Sampler's sample tab now hold the material. track/scene targets put rendered audio straight into
a clip slot.
Two things to know about a render job:
- Determinism. The spec is fixed at enqueue time (params resolve
against a fresh prototype the moment you call
yawn.render, and the whole spec is a value copy). Same spec, same audio — every time. Addseed = 42when using generative sources for the same guarantee. - Job lifecycle.
yawn.renderreturns a job id;yawn.cancel_render(id)cancels; failures (unknown device, bad param name) surface in the console immediately with the reason, not three layers down.
Other targets: { kind = "granular" | "vocoder" | "drumslop", track = n },
{ kind = "drumrack", track = n, pad = k } (explicit pad — no side
effects on whatever pad is selected), { kind = "clip", track = n, scene = m }, { kind = "library", name = "..." } (indexed into the content
browser), and { kind = "file", path = "..." } (straight to disk, your
scratch experiments).
yawn.render composes with everything else: a routed render can be the
result of a generative process, and its file/clip/sampler delivery then
pays off in Lesson 7 — but you can just as well use it as a "print this
sound" button.
You can also synthesize samples in the script — sample by sample, or shaped in the frequency domain — and place them straight into the project:
-- Lesson 7 — the buffer forge
song = {
bpm = 110,
tracks = {
{ uid = 1, name = "Pad", type = "audio", volume = 0.8 },
{ uid = 2, name = "Ticks", type = "midi",
instrument = { id = "sampler", params = {
["Root Note"] = 0.3125 } }, -- pitch 60 = C4
}
},
}
local sr = yawn.buffer_info(yawn.new_buffer{ frames = 1 }).sr
local twoPi = 2 * math.pi
-- ── an additive pad: one sine voice per harmonic, stereo-detuned ──
local HARMONICS = 8
local PAD = yawn.new_buffer{
frames = sr * 4, channels = 2,
fill = function(f, ch)
local t = (f - 1) / sr
local acc = 0.0
for k = 1, HARMONICS do
acc = acc + (1.0 / (k * k)) *
math.sin(twoPi * 110 * k * (ch == 1 and 1.0 or 1.003) * t)
end
return acc * math.exp(-t * 1.2)
end,
}
yawn.buffer_normalize(PAD, 0.8)
-- ── a shaped-noise tick: white noise through an exponent decay ──
local NF = math.floor(sr * 0.08)
local HAT = yawn.new_buffer{
frames = NF, channels = 1,
fill = function(f)
local t = (f - 1) / sr
return 0.8 * math.exp(-t * 90) * (math.random() * 2 - 1)
end,
}
yawn.buffer_fade(HAT, 4, NF // 4) -- 4-frame fade-in, quarter fade-out
-- ── spectral surgery: FFT → bin mask → IFFT ──
local mono = yawn.buffer_mixdown(PAD)
local raw = yawn.buffer_data(mono).data[1]
local n = 65536
local x = {} -- yawn.fft needs a power of two:
for i = 1, n do x[i] = raw[i] end -- copy the first 65536 frames
local spec = yawn.fft(x)
local knee = n // 8 -- everything above rolls off 24 dB/oct
for k = knee + 1, n do
local g = 0.5 ^ ((k - knee) / (n // 24))
spec[2 * k - 1] = (spec[2 * k - 1] or 0) * g
spec[2 * k] = (spec[2 * k] or 0) * g
end
local SHAPED = yawn.new_buffer{
frames = n, channels = 1, data = { yawn.ifft(spec) } }
yawn.buffer_normalize(SHAPED, 0.8)
-- ── the placements ──
yawn.set_clip(0, 1, PAD, "forged pad") -- 4-s stereo loop in scene 1
yawn.load_sample(1, HAT, "sampler") -- the tick, playable live
improv.every(0.5, function(beat) -- ghost ticks over the pad
if math.random() < 0.6 then
yawn.note(1, "C4", 100, 0.05, 0, beat)
end
end)
yawn.launch_scene(1)
-- @assert tracks:2
-- @assert playing
-- @assert audioclip:0:1The full shaping vocabulary: yawn.buffer_gain, buffer_normalize,
buffer_fade(in, out), buffer_mix(dst, src, level), buffer_reverse,
plus the new-handle constructors buffer_slice, buffer_concat,
buffer_repeat, buffer_mixdown; and the math helpers yawn.fft /
yawn.ifft (interleaved re/im tables, power-of-two length) and
yawn.polyblep(phase, dt) (alias-free saw edges — the classic fix for a
naive oscillator). Handles are freeable (yawn.free_buffer), and
buffer_data round-trips samples back out as Lua tables for inspection.
Two placement functions differ in one respect worth internalizing:
yawn.set_clip(track, scene, handle, [name]) puts a buffer into a session
slot as an audio clip (launchable, loopable, arrangement-able), while
yawn.load_sample(track, handle, [kind]) publishes it into a
sampler/granular/drumrack-family instrument so notes can trigger it.
The first is a song; the second is a voice. (Sustained forges usually want
the clip; one-shots usually want the sampler — and nothing stops you from
doing both, which is exactly what Lesson 6's kick job could also do.)
If the visual engine is more your stage: yawn.set_visual(track, scene, "31_ghost_accent.frag", "ghost accents") puts one of YAWN's bundled
shaders on a visual track as a clip — and the improv layer's upcoming
fires are published to shaders as ghost uniforms (iGhost0..7,
iBeatBarFrac), which is how the ghost-accent shader pre-glows notes just
before they sound. See visual.md; see example
05_sample_synth.lua for a full workstation script that synthesizes an
entire kit plus visuals from math.
A handful of platform facts that round out the picture:
The sandbox. Live code runs in a separate Lua state from everything
else, confined to base/math/table/string — io, os, package,
debug, dofile, loadfile and require don't exist. A count-based
instruction budget (a generous 400M instructions for a full script run,
250k per scheduled callback) turns runaway loops into a clean console
error instead of a frozen UI. And the engines don't strangle each other:
live code and controller scripts are independent Lua states — one crashing
takes neither down.
Ownership and persistence. song-declared items are script-owned and
re-asserted on every apply; imperatively-created items (a param you set
live, an instrument you picked in the UI) are ordinary project state.
Everything materialized — tracks, instruments, effect chains, clips, even
rendered samples — is saved into project.json normally, so a project
built half-by-code plays back perfectly with live coding switched off.
(The raw notes the improv layer fires are the exception — the Freeze
button below is how they become durable.) The script file itself lives in the
project's livecode/ folder (the manager scans it on save; more .lua
files, each runnable; autorun is opt-in in the project's livecode
section).
The improv ledger — freezing takes. The improv layer is performance
only by design: nothing it fires changes the project (that's why a
re-run doesn't erase your previous notes). When a take is good enough to
keep, click Freeze on any ~ console tab: every note the layer has
fired since the last freeze is serialized into a normalized MIDI clip on
a new sidecar Take N track — and sidecar tracks are invisible to the
song reconciler, so your freeze survives re-runs, reloads, and save/load.
Failure modes, catalogued.
| Console message | Meaning, and the fix |
|---|---|
unknown param 'Cutoff' (valid: …) |
A param name didn't resolve; the valid list is right there. Match it (case doesn't matter). |
render: unknown device id 'synt' |
Factory ids are exact (subsynth, not synth). |
audio: track 2 has no matching sampler instrument |
A load_sample/sampler delivery targeted a track without that instrument — declare it in song and run again. |
live code: run failed — previous generation continues |
A script error; the last-good layer is still performing. Read the error just above, fix, Ctrl+L. |
live code instruction budget exceeded |
A loop ran past the budget (400M instructions per run / 250k per callback). Move heavy per-sample math into yawn.new_buffer fill functions (they get the big budget) — callbacks must stay small. |
| A callback stopped firing silently | After 3 consecutive dispatch errors that callback is auto-cancelled; the console recorded them. Check the log, clear it, re-run. |
late drop counts climbing |
Your machine hitched past the lookahead. Thicken the pattern, raise improv.lookahead (up to 0.5 s), or accept late_policy("drop")'s silence-better-than-late trade. |
Keep going: the five demo songs in assets/examples/livecode/ are the
next rung (01 declarative sketch, 02 improv-only performer, 03 the
two-layer convention, 04 a drum-kit forge, 05 the buffer-from-math
workstation) — the suite runs them, so you can read them as executable
documentation. live-coding.md is the full API surface
and scheduling model; visual.md takes shaders and ghosts
from here. The notations keyboard, the code lens and Ctrl+Enter are worth
fingering: the tool chain — console to run, run to console — is the
muscle this whole feature exercises.
Everything the song layer's instrument = { id = … } / fx = { … }
declarations and yawn.render{ device = … } accept, with every parameter
name. Code uses the id (backticks below); the menus and details panel
show the display name; param names are matched case-insensitively and
can be given by index instead (the list order below). A wrong name
prints a warning listing the exact valid set — this table is the same
information, offline.
Instruments (instrument = { id = …, params = { … } }):
| id | Display name | Parameters |
|---|---|---|
subsynth |
SubSynth | Osc1 Wave, Osc1 Level, Osc2 Wave, Osc2 Level, Osc2 Detune, Osc2 Octave, Sub Level, Noise Level, Filter Cutoff, Filter Reso, Filter Type, Filter Env, Amp Attack, Amp Decay, Amp Sustain, Amp Release, Filt Attack, Filt Decay, Filt Sustain, Filt Release, LFO Rate, LFO Depth, Volume |
fmsynth |
FM Synth | Algorithm, Feedback, Op1 Level, Op1 Ratio, Op1 Attack, Op1 Release, Op2 Level, Op2 Ratio, Op2 Attack, Op2 Release, Op3 Level, Op3 Ratio, Op3 Attack, Op3 Release, Op4 Level, Op4 Ratio, Op4 Attack, Op4 Release, Volume |
sampler |
Sampler | Root Note, Attack, Decay, Sustain, Release, Filter Cut, Filter Reso, Volume, Loop Start, Loop End, Reverse, Sample Gain |
drumrack |
Drum Rack | Volume, Pad Volume, Pad Pan, Pad Pitch, Choke, Attack, Decay, Start, End |
drumsynth |
Drum Synth | Tune, Atk, Dec, Sine, White, Pink, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, Tune, Atk, Dec, Drive, OS 2x |
drumslop |
DrumSlop | Slice Count, Slice Mode, Orig BPM, Base Note, Swing, Volume, Pad Vol, Pad Pan, Pad Pitch, Pad Rev, Pad Cutoff, Pad Reso, Pad Atk, Pad Dec, Pad Sus, Pad Rel |
karplus |
Karplus-Strong | Exciter, Damping, Brightness, Decay, Body, Stretch, Attack, Volume |
wavetable |
Wavetable Synth | Table, Position, Filter Cut, Filter Res, Filter Env, Amp Atk, Amp Dec, Amp Sus, Amp Rel, Filt Atk, Filt Dec, Filt Sus, Filt Rel, LFO Rate, LFO Amt, Sub Level, Unison Det, Volume |
granular |
Granular Synth | Position, Grain Size, Density, Spread, Pitch, Spray, Shape, Scan, Filter Cut, Filter Reso, Attack, Release, Stereo Width, Reverse, Jitter, Volume, Sidechain In |
vocoder |
Vocoder | Bands, Carrier Type, Mod Source, Bandwidth, Env Attack, Env Release, Formant Shift, HF Tilt, Unvoiced, Dry Carrier, Amp Attack, Amp Release, Output Filter, Volume, Freeze, Detune, Talkbox |
multisampler |
Multisampler | Amp Attack, Amp Decay, Amp Sustain, Amp Release, Filter Cut, Filter Reso, Filt Env Amt, Filt Attack, Filt Decay, Filt Sustain, Filt Release, Glide, Vel Crossfade, Volume |
instrack |
Instrument Rack | Volume, Chain Vol, Chain Pan, Key Low, Key High, Vel Low, Vel High |
stringmachine |
String Machine | 16' Level, 8' Level, 4' Level, Detune, Attack, Release, Brightness, Ens Depth, Ens Rate, Ens On, Volume |
drawbarorgan |
Drawbar Organ | 16', 5 1/3, 8', 4', 2 2/3, 2', 1 3/5, 1 1/3, 1', Perc, Harm, Decay, Loud, Click, Vibrato, Chorus, Volume |
electricpiano |
Electric Piano | Mode, Bell, Hardness, Hammer, Attack, Decay, Release, Trem Rate, Trem Depth, Volume |
Useful enums read as floats: subsynth's Osc1 Wave is 0 sine, 1 triangle,
2 saw, 3 square, 4 noise, and its Filter Type is 0 low-pass, 1
band-pass, 2 high-pass (the filter effect's own Type uses the same
0/1/2 vocabulary).
Audio effects (fx = { { id = …, params = { … } }, … } or
yawn.render{ fx = { … } }):
| id | Display name | Parameters |
|---|---|---|
reverb |
Reverb | Room Size, Damping, Wet/Dry, Pre-Delay, Width |
plate |
Plate Reverb | Pre-Delay, Decay, Damping, Diffusion, Width, Wet/Dry |
delay |
Delay | Time, Feedback, Wet/Dry, Ping-Pong, Filter, Tempo Sync |
eq |
EQ | Low Freq, Low Gain, Mid Freq, Mid Gain, Mid Q, High Freq, High Gain |
compressor |
Compressor | Threshold, Ratio, Attack, Release, Makeup Gain, Knee |
limiter |
Limiter | Ceiling, Release, Lookahead |
filter |
Filter | Cutoff, Resonance, Type |
chorus |
Chorus | Rate, Depth, Wet/Dry, Voices |
phaser |
Phaser | Rate, Depth, Feedback, Stages, Center, Stereo, Mix |
wah |
Wah | Mode, Pedal, Bottom, Top, Q, Sensitivity, Response, Mix |
rotary |
Rotary | Speed, Accel, Drive, X-Over, H Slow, H Fast, D Slow, D Fast, Mic Wd, Mix, OS 2x |
autopanner |
Auto Panner | Rate, Sync, Division, Depth, Waveform, Phase, Mix |
distortion |
Distortion | Drive, Tone, Wet/Dry, Type, OS 2x |
bitcrusher |
Bitcrusher | Bits, Sample Rate, Pre Filter, Dither, Mix, OS 2x |
beatrepeat |
Beat Repeat | Interval, Repeat, Gate, Chance, Pitch, Mix |
bufferrepeat |
Buffer Repeat | Size, Repeats, Chance, Mix |
resampler |
Resampler | Rate, Window, Mix |
clockdrift |
Clock Drift | Depth, Rate, Jitter, Mix |
cderror |
CD Error | Rate, Size, Severity, Mix |
noisegate |
Noise Gate | Thresh, Hyst, Atk, Hold, Rel, Look, Range, SC, Duck, Mix |
pingpongdelay |
Ping-Pong Delay | Time L, Time R, FB, Cross, Width, Filter, Sync, Mix |
envfollower |
Envelope Follower | Src, Mode, Sens, Atk, Rel, Type, Cut, Depth, Range, Q, Mix |
splineeq |
Spline EQ | On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q, On, Type, Freq, Gain, Q |
neuralamp |
Neural Amp | In, Out, Mix, Gate, Bass, Mid, Treble, Norm |
convreverb |
Conv Reverb | PreDly, LoCut, HiCut, IR Gain, Mix |
tape |
Tape Emulation | Saturation, Wow, Flutter, Hiss, Tone, Wet/Dry, OS 2x |
amp |
Amp Simulator | Gain, Bass, Mid, Treble, Presence, Output, Type, Cabinet, OS 2x |
oscilloscope |
Oscilloscope | Time, Gain, Freeze |
spectrum |
Spectrum | Smooth, Gain, Freeze |
tuner |
Tuner | Gain, A4 Ref |
(The splineeq rows repeat — one band per group. VST3 plugins are their
own id family and not listed here.)
yawn.note takes "C2"-style names or plain numbers, and the notation
keywords use the same scale: [A-G], optional # (sharp) or
b (flat), then an octave number, with MIDI 60 = C4 — so MIDI 0
is C-1, and A1 is 33 while F#2 is 42. When your code transposes
(a root note moving by semitones, an octave jump), keep the base as a
number and add — arithmetic doesn't work on note-name strings:
yawn.note(0, "C2", 127, 0.3, 0, beat) -- names: don't do root + var
yawn.note(0, 36 + var % 12, 127, ... ) -- numbers: transposableThe yawn.drums lanes map onto the GM drum set (channel 9); the same
pitches are just note names in yawn.note:
| Lane names | GM note | Name |
|---|---|---|
kick / bd |
36 | C2 |
rim / rs |
37 | C#2 |
snare / sn |
38 | D2 |
clap / cp |
39 | D#2 |
hihat / hh / ch (closed) |
42 | F#2 |
openhh / oh |
46 | A#2 |
floortom / ft |
43 | G2 |
lowtom / lt |
45 | A2 |
tommid / tom / tm |
48 | C3 |
hightom / ht |
50 | D3 |
crash / cr / cymbal |
49 | C#3 |
ride / rd |
51 | D#3 |
Step characters (X 0.88 / x 0.76 / o 0.6 / g 0.31) and their
per-step positions work identically whether the grid feeds a fresh
Drum Rack (Pads are keyed by GM note), a drum synth, or anything that
answers notes on channel 9.