Skip to content

5. Song Structure

So far every cycle sounds the same. This tutorial shows how to make music that changes over time.

ctx — the cycle context

Every song(ctx) call receives a context object:

PropertyTypeMeaning
ctx.cyclenumberCurrent cycle (0-indexed, increments each cycle)
ctx.beatsPerCyclenumberCurrent beats per cycle setting
ctx.rand()functionSeeded random number 0–1 (reproducible)

Use ctx.cycle to make things change:

function* song(ctx) {
const synth = vasynth({ wave: 'sawtooth', release: 0.3 })
const key = scales(4, 'C4:minor')
// Different melody on even vs odd cycles
const melody = ctx.cycle % 2 === 0
? seq(4, '0,2,4,3')
: seq(4, '4,3,2,0')
yield cast(synth, key, melody, ctx)
}

Conditional parts

Add a hi-hat only after cycle 4, and a second melody after cycle 8:

function* song(ctx) {
const lead = vasynth({ wave: 'triangle', release: 0.3 })
const hh = hat()
const key = scales(4, 'D4:minor')
yield cast(lead, key, seq(4, '0,2,4,3,2,0,_,_'), ctx)
if (ctx.cycle >= 2) {
yield cast(hh, 'drums', seq(4, 'x.x.x.x.'), ctx)
}
if (ctx.cycle >= 4) {
const pad = vasynth({ wave: 'sawtooth', cutoff: 600, attack: 0.6, gain: 0.25 })
yield cast(pad, key, seq(4, '{0,2,4},~,~,~'), ctx)
}
}

Four-bar phrase structure

Group cycles into 4-bar phrases using modulo:

const phase = ctx.cycle % 4 // 0, 1, 2, 3, 0, 1, 2, 3, ...
// Variation on bar 4 (phase 3)
const melody = phase < 3
? seq(4, '0,2,4,3,2,4,0,_')
: seq(4, '4,3,2,0,_,_,_,_') // fill / turnaround
yield cast(lead, prog, melody, ctx)

Meta events — tempo and time signature

Yield meta events alongside patterns to change global settings mid-song.

Tempo changes

yield { type: 'tempo', bpm: 140 } // change to 140 BPM immediately
yield { type: 'tempo', bpm: 120, once: true } // only on cycle 0, stays until changed again

Beats per cycle

yield { type: 'bpc', bpc: 8 } // switch to 8 beats per cycle
yield { type: 'bpc', bpc: 3 } // 3/4 waltz feel

Master FX from meta events

yield { type: 'fx_reverb', mix: 0.4 }
yield { type: 'fx_delay', time: 0.375, feedback: 0.4 }
yield { type: 'fx_gain', gain: 0.8 }

Conditional tempo ramp

function* song(ctx) {
const bpm = 100 + ctx.cycle * 2 // speeds up by 2 BPM each cycle
yield { type: 'tempo', bpm: Math.min(bpm, 160) } // cap at 160
const synth = vasynth()
const key = scales(4, 'C4:major')
yield cast(synth, key, seq(4, '0,2,4,2'), ctx)
}

arrange() — named sections

arrange() lets you define a song as named sections that play in order. Each section runs for a set number of cycles.

function* song(ctx) {
yield* arrange(ctx, [
{
name: 'intro',
cycles: 4,
patterns: (c) => [
cast(lead, key, seq(4, '0,2,4,2'), c),
],
},
{
name: 'verse',
cycles: 8,
patterns: (c) => [
cast(lead, prog, seq(4, '0,2,4,3,2,0,_,_'), c),
cast(bass, prog, seq(4, '0,_,4,_'), c),
cast(kk, 'drums', seq(4, 'x...x.x.'), c),
],
},
{
name: 'chorus',
cycles: 8,
crossfade: 1,
patterns: (c) => [
cast(lead, prog2, seq(4, '4,3,2,0,4,3,2,_'), c),
cast(pad, prog2, seq(4, '{0,2,4},~,~,~'), c),
cast(bass, prog2, seq(4, '0,_,4,_'), c),
cast(kk, 'drums', seq(4, 'x.x.x.x.'), c),
cast(sn, 'drums', seq(4, '..x...x.'), c),
],
},
])
}

The patterns field is a function receiving the current IncantoContext — use it to access cycle, rand(), etc.

crossfade: N blends the previous section over N cycles for smooth transitions.

Full arrange example — verse/chorus song

function* song(ctx) {
const lead = vasynth({ wave: 'triangle', cutoff: 3500, release: 0.3, gain: 0.55 })
const pad = vasynth({ wave: 'sawtooth', cutoff: 600, attack: 0.5, release: 1.5, gain: 0.2 })
const bass = vasynth({ wave: 'square', cutoff: 350, release: 0.15, gain: 0.6 })
const kk = kick()
const sn = snare()
const hh = hat({ decay: 0.04 })
const key = scales(4, 'A4:minor')
const bKey = key.transpose(-24)
const prog = chords(8, key, 'I,VII,VI,VII')
const bProg = prog.transpose(-24)
yield* arrange(ctx, [
{
name: 'intro',
cycles: 4,
patterns: (c) => [
cast(pad, prog, seq(4, '{0,2,4},~,~,~'), c),
],
},
{
name: 'verse',
cycles: 8,
crossfade: 1,
patterns: (c) => [
cast(lead, prog, seq(4, '0,2,4,3,2,0,_,_'), c),
cast(bass, bProg, seq(4, '0,_,_,4,0,_,2,_'), c),
cast(kk, 'drums', seq(4, 'x...x.x.....x...'), c),
cast(sn, 'drums', seq(4, '....x.......x...'), c),
cast(hh, 'drums', seq(4, 'x.x.x.x.x.x.x.x.'), c),
],
},
{
name: 'chorus',
cycles: 8,
crossfade: 2,
patterns: (c) => [
cast(lead, prog, seq(4, '4,3,2,4,7,4,2,0'), c),
cast(pad, prog, seq(8, '{0,2,4},~,~,~,{0,2,4},~,~,~'), c),
cast(bass, bProg, seq(4, '0,_,_,4,0,_,2,_'), c),
cast(kk, 'drums', seq(4, 'x.x.x.x.x.x.x.x.'), c),
cast(sn, 'drums', seq(4, '..x...x.'), c),
cast(hh, 'drums', seq(2, 'x.x.'), c),
],
},
{
name: 'outro',
cycles: 4,
crossfade: 2,
patterns: (c) => [
cast(pad, prog, seq(4, '{0,2,4},~,~,~'), c),
],
},
])
}

Ending a song

arrange() ends the song after all sections complete. For manual control, yield { type: 'eos' }:

if (ctx.cycle >= 16) yield { type: 'eos' }

Next

6. Generative Music → — modifiers, Markov chains, L-systems, and sidechain routing.