song.jsannotatedsong.jssource278 lines · 14.5 KB · raw

LIGHTNING IN A BOTTLE — metal anthem, 150bpm, 64 bars as written; Jev arranges 7-16 sections. Spec: SPEC.md Start from STOPPED so the song begins at bar 1 (re-running it mid-play keeps the cycle count).

3setcps(150/60/4)
4await samples('github:tidalcycles/dirt-samples')
5await samples('jev-samples/strudel.json')

bars: 1-8 intro | 9-16 riff | 17-24 verse | 25-32 chorus | 33-40 build 41-48 drop | 49-56 drop2 | 57-64 outro (each mask = 64 steps) Where each section starts as written: the form (walk) and the text Jev is told (playsIn) both read it.

11const at = { intro: 0, riff: 8, verse: 16, chorus: 24, build: 32, drop: 40, drop2: 48, outro: 56 }
13const introBars  = "<1!8 0!56>"
14const afterIntro = "<0!8 1!56>"
15const quoteOn    = "<0 0 1 0!61>"
16const riffPedal  = "<0!8 1!8 0!48>"
17const riffChord  = "<0!16 1!16 0!24 1!4 0!4>"   // verse, chorus, outro
18const riffBuild  = "<0!32 1!8 0!24>"
19const riffHigh   = "<0!24 1!16 0!24>"           // chorus and build; the drop has its own
20const bassRoots  = "<0!16 1!44 0!4>"
21const rock       = "<0!8 1!16 0!8 1!8 0!16 1!4 0!4>"
22const chorus     = "<0!24 1!8 0!32>"
23const hats       = "<0!8 1!32 0!16 1!4 0!4>"
24const snare      = "<0!8 1!52 0!4>"
25const drop       = "<0!40 1!16 0!8>"
26const power      = "<0!24 1!8 0!8 1!16 0!8>"
27const verse      = "<0!16 1!8 0!40>"
28const outroVox   = "<0!56 1 0!7>"
29const fills      = "<0!7 1 0!56>"
30const roll39     = "<0!38 1 0!25>"
31const roll40     = "<0!39 1 0!24>"
32const riserBars  = "<0!36 1!4 0!24>"
33const shotgun    = "<0!8 1 0!23 1 0!7 1 0!23>"
34const crash      = "<0!8 1 0!17 1 0 1 0 1 0!9 1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0!9>"   // 9, 27, 29, 31, the drop's chords
35const crashSoft  = "<0!24 1 0!39>"              // 25, under the sung "Th-"
36const final      = "<0!60 1 0!3>"

Each part's masks, the one place that says where it plays: the parts below play from these, and the text Jev is told is derived from them.

40const where = {
41  riff: [riffPedal, riffChord, riffBuild, riffHigh, drop],
42  chords: [power],
43  bass: [introBars, bassRoots],
44  drums: [fills, rock, chorus, hats, snare, drop, roll39, roll40, riserBars, crash, crashSoft, shotgun],
45  vocals: [verse, chorus],
46}

a mask is on in a section if the pattern itself has an event there

48const onIn = (mask, from, to) => mask.queryArc(from, to).some((h) => h.value && Number(h.value) !== 0)
49const sectionNames = Object.keys(at)
50const sectionsOf = (masks) => sectionNames.filter((name) =>
51  masks.some((m) => onIn(m, at[name], at[name] + 8)))
52const playsIn = (partName) => 'plays in: ' + sectionsOf(where[partName]).join(', ')
53const plays = (section) => 'plays: ' + Object.keys(where).filter((p) => sectionsOf(where[p]).includes(section)).join(', ')

── Jev: two of them, in TypeSafe's own shape (SPEC.md, "Jev in the booth") ── formJev picks the next section; djJev, asked once that is decided (questions in one request cannot see each other), says how much of each part plays it, how the song moves into it, and how hard. Every 8 bars. The form's rules (walk, at the bottom) narrow the sections Jev may pick and end the song; without Jev it plays as written.

60const song = {
61  title: 'LIGHTNING IN A BOTTLE',
62  what: 'a metal anthem on the Doom E1M1 riff, about using Jev properly: code decides what code can decide, Jev takes the rest',
63  tempo: '150 bpm, 4/4, 8 bars a section',
64  key: 'E minor',
65  chords: 'Em Em C C G G D D, two bars each, under the riff and the power chords',
66  lyrics: {
67    intro: 'We have lightning in a bottle, and yet it is not useful.',
68    verse: 'Remove the human from the loop / Routine calls never leave the machine / Only the residue goes to Jev / Forty-four out of forty-four',
69    chorus: 'The code decides what code can decide / Jev takes the rest',
70    outro: 'The code decides what code can decide',
71  },
72}
74const formJev = jev({
75  state: song,
76  questions: {
77    section: choice('Pick the section of the song that plays next, from those allowed. The outro ends the song: choose it once the song has built to its peak and made its point, before it drags', {
78      intro: 'a sub pulse and a plain robot voice reading the quote; ' + plays('intro'),
79      riff: 'the E1M1 riff on one note, the rock beat starts, a shotgun blast; ' + plays('riff'),
80      verse: 'the riff follows the chords under the vocoded verse: "Remove the human from the loop … Forty-four out of forty-four"; ' + plays('verse'),
81      chorus: 'the chorus: power chords, the riff an octave up, a fuller beat, the hook sung; ' + plays('chorus'),
82      build: 'the build: the riff\'s filter opening, a noise riser, a snare roll into the drop; ' + plays('build'),
83      drop: 'the drop, first half: sixteenth double kick, the guitars wide open, power chords, crashes; ' + plays('drop'),
84      drop2: 'the drop, second half, the same; ' + plays('drop2'),
85      outro: 'the last hook line, then the E power chord and a crash left to ring: the end; ' + plays('outro'),
86    }),
87  },
88})

how much of each part the section decidedThisSection has: out, back or full

91const level = (part, what) => score('How much of ' + part + ' (' + what + '; ' + playsIn(part) + ') should the next section (decidedThisSection) have? Drop parts to build tension or make space, pull them back to let others speak, and bring them in full for the payoff; keep what defines the section', [
92  'out: silent for the section',
93  'back: quieter than written',
94  'full: at its written level',
95])
96const djJev = jev({
97  state: song,
98  questions: {
99    riff: level('riff', 'the E1M1 riff, the song\'s signature'),
100    chords: level('chords', 'the power chords, the weight of the band'),
101    bass: level('bass', 'the sub on the chord roots, low'),
102    drums: level('drums', 'the drums, the crashes and the shotgun'),
103    vocals: level('vocals', 'the vocoded verse and chorus lines'),
104    into: choice('How should the song move into the next section (decidedThisSection)? It plays over the last bar of the section before it', {
105      cut: 'straight in, no transition',
106      fill: 'a drum fill down the toms into the first beat',
107      riser: 'white noise swelling up over the last bar',
108      dropout: 'everything stops for the last beat, a gap before the section hits',
109    }),
110    energy: score('How intense should the next section be? Shape the energy of the whole song, following its history, how it measured and how the listener reacted', [
111      'held back: quieter',
112      'steady: a little under full',
113      'driving: full volume',
114      'all out: full volume, nothing held back',
115    ]),
116  },
117})

a little chance in the form: the section is drawn from Jev's probabilities

120const { section } = formJev.every(8, { sample: 0.7 })

The build has its own riser, so the move into the drop is never a riser.

122const dj = djJev.every(8, {
123  after: { section },
124  forget: ['into'],
125  allow: { into: ({ section: next }) => (next === 'drop' ? ['cut', 'fill', 'dropout'] : ['cut', 'fill', 'riser', 'dropout']) },
126  fallback: { riff: 2, chords: 2, bass: 2, drums: 2, vocals: 2, into: 'cut', energy: 3 },
127})

how the song moves into the next section, over the last bar of each

130const into = dj.into.choice.early(1)
131const onCutOr = (...moves) => (c) => moves.includes(c)

A part at Jev's level: silent when out, otherwise its velocity scaled by the level. The intro is not Jev's: it plays every part as written (level 2). Parts are composed inside the arrangement, so these are written bars. Each part has its own orbit, named for its question, so the meter hears it alone (measuredSound).

138const levelOf = (name) => stack(pure(2).mask(introBars), dj[name].score.mask(afterIntro))
139const part = (name, pat) => {
140  const lvl = levelOf(name)
141  return pat.orbit(name).mask(lvl.round().gt(0)).mul(velocity(lvl.round().div(2)))
142}

energy scales the parts together; the intro plays at full

144const energy = stack(pure(1).mask(introBars), dj.energy.score.div(3).range(.55, 1).mask(afterIntro))

E1M1 ("At Doom's Gate"), eighths, MIDI numbers: E2=40 E3=52 D3=50 C3=48 A#2=46 B2=47

147const riff = "<[40 40 52 40 40 50 40 40] [48 40 40 46 40 40 47 48] [40 40 52 40 40 50 40 40] [48 40 40 46@5]>"

two bars per chord: Em Em C C G G D D — the riff follows the root

149const shift  = "<0!2 -4!2 3!2 -2!2>"
150const roots  = "<28!2 24!2 31!2 26!2>"   // E1 C1 G1 D1
151const chords = "<[e2,b2,e3]!2 [c2,g2,c3]!2 [g2,d3,g3]!2 [d2,a2,d3]!2>"

a dropout's gap is silent: the section's last bar is cut short before its last beat, so the held A#2 does not ring through it

155const gapClip = stack(pure(1).mask("<1!7 0>"), into.fmap((c) => (c === 'dropout' ? .6 : 1)).mask("<0!7 1>"))

── parts ─────────────────────────────────────────

158const saw1 = (p) => p.s("sawtooth").decay(.14).sustain(.2).distort(1.5)
159const gtr = stack(
160  saw1(note(riff)).lpf(1500).gain(.35).mask(riffPedal),
161  saw1(note(riff).add(note(shift))).lpf(1500).gain(.35).mask(riffChord),
162  // the build: the filter opens from dull to bright across its 8 bars
163  saw1(note(riff).add(note(shift))).lpf(saw.slow(8).range(700, 3000)).gain(.35).mask(riffBuild),
164  // the drop: the guitars open up
165  saw1(note(riff).add(note(shift))).lpf(3000).gain(.35).mask(drop),
166  note(riff).add(note(shift)).add(note(12)).s("square").decay(.1).sustain(.1).lpf(2500).gain(.14).mask(riffHigh),
167  note(riff).add(note(shift)).add(note(12)).s("square").decay(.1).sustain(.1).lpf(3000).gain(.25).mask(drop),
168).clip(gapClip)

power chords, chorus and drop

171const stabs = note(chords).struct("x ~ ~ x ~ ~ x ~").s("sawtooth").decay(.3).sustain(.3)
172  .lpf(2200).distort(2).gain(.22).mask(power)

sub: a pulse in the intro, then the chord roots, each with a triangle an octave up so small speakers hear it

176const sub = stack(
177  note("28*4").s("sine").decay(.35).sustain(0).gain(.7).mask(introBars),
178  note("40*4").s("triangle").decay(.35).sustain(0).lpf(300).gain(.25).mask(introBars),
179  note(roots).struct("x x x x").s("sine").decay(.3).sustain(.3).gain(.55).mask(bassRoots),
180  note(roots).add(note(12)).struct("x x x x").s("triangle").decay(.3).sustain(.3).lpf(250).gain(.2).mask(bassRoots),
181)
183const kit = stack(
184  s("bd:3").struct("x ~ ~ x x ~ ~ ~").gain(.6).mask(rock),
185  s("bd:3").struct("x ~ x x x ~ x ~").gain(.6).mask(chorus),
186  s("bd:3").struct("x*16").gain(.3).mask(drop),
187  s("808bd:1*4").gain(.2).mask(drop),
188  s("sn:3").struct("~ ~ x ~ ~ ~ x ~").room(.3).gain(.52).mask(snare),
189  s("hh*8").gain(".16 .08").mask(hats),
190  s("cr*4").gain(.25).mask(drop),   // cr is a ride
191  // the intro's written fill: only when the move into the riff is a cut
192  s("ht ht mt mt lt lt lt lt").gain(.5).mask(fills).mask(into.fmap(onCutOr('cut'))),
193  // the build's roll, one crescendo over bars 39-40: only on a cut or a
194  // dropout (bar 39 looks two bars ahead for the drop's answer)
195  s("sn:3*16").gain(saw.slow(2).range(.16, .55)).mask(roll39).mask(dj.into.choice.early(2).fmap(onCutOr('cut', 'dropout'))),
196  s("sn:3*16").gain(saw.slow(2).range(.16, .55)).mask(roll40).mask(into.fmap(onCutOr('cut', 'dropout'))),
197  // the build's riser, bars 37-40
198  s("white*16").hpf(saw.slow(4).range(400, 8000)).gain(saw.slow(4).range(.03, .25)).decay(.07).sustain(0).mask(riserBars),
199  s("hh27:1").gain(3).mask(crash),       // hh27:1 is the crash
200  s("hh27:1").gain(2.1).mask(crashSoft), // 3 dB down, under "Th-"
201  s("lbshotgun").gain(.85).mask(shotgun),
202)
203
204const vox = stack(
205  s("<lbremove ~ lbroutine ~ lbresidue ~ lbfortyfour ~>").gain(.85).room(.2).mask(verse),
206  s("<lbcodeem ~ lbcodec ~ lbrestg ~ lbrestd ~>").gain(.85).room(.25).mask(chorus),
207)

the story: the quote and the outro's hook line. Not Jev's: they always play.

210const story = stack(
211  s("lbquote").gain(.85).room(.4).mask(quoteOn),
212  s("lbcodeem").gain(.85).room(.4).mask(outroVox),
213).orbit('vocals')

the end: E power chord, crash, kick and shotgun, left to ring. It always plays.

216const ending = stack(
217  note("[e2,b2,e3]").s("sawtooth").decay(5.5).sustain(0).lpf(2000).distort(1.5)
218    .room(.7).size(.95).gain(.3).mask(final),
219  s("[bd:3,808bd:1]").room(.6).gain(.5).mask(final),
220  s("hh27:1").room(.6).gain(2.5).mask(final),
221  s("lbshotgun").gain(.85).mask(final),
222).mul(gain(.45)) // with the band: measured 6 dB over it at full, the master's peak
224const once = (p) => p.mask("<1!64 0!936>")   // play once, then silence

── the form, as rules in code: which sections may follow which ── at is where each starts as written; an empty next ends the song. The written order is always allowed, and is what plays without Jev. One drop: drop2 never leads back into it.

230const form = walk(section, {
231  max: 16,
232  sections: {
233    intro: { at: at.intro, next: ['riff'] },
234    riff: { at: at.riff, next: ['verse', 'chorus'] },
235    verse: { at: at.verse, next: ['chorus', 'riff'] },
236    chorus: { at: at.chorus, next: ['build', 'verse'] },
237    build: { at: at.build, next: ['drop'] },
238    drop: { at: at.drop, next: ['drop2'] },
239    drop2: { at: at.drop2, next: ['verse', 'chorus', 'outro'] },
240    outro: { at: at.outro, next: [] },
241  },
242})

── the song: Jev's section, parts and energy, composed ── Each section plays its own bars, each part at the level Jev chose; the energy scales the parts' velocity together, so the mix keeps its balance. The story and the ending always play.

248const parts = form.arrange(once(stack(
249  part('riff', gtr.mul(gain(.8))),
250  part('chords', stabs.mul(gain(.8))),
251  part('bass', sub),
252  part('drums', kit.mul(gain(.42))), // the drums 7.5 dB down: measured 10 dB over the vocals
253  part('vocals', vox),
254).mul(velocity(energy))))

── into the next section: over the last bar of each, the next one's answer ── Past the last section there is no next one, so nothing plays there. The fill is the drums', at the level of the section it ends; this is played time, not written bars, so the intro (always the first 8) plays it full.

260const drumsNow = stack(pure(2).mask("<1!8 0!992>"), dj.drums.score.mask("<0!8 1!992>"))
261const transition = into.pick({
262  cut: silence,
263  fill: s("ht ht mt mt lt lt [lt lt] [sn:3 sn:3]").gain(.5).orbit('drums')
264    .mask(drumsNow.round().gt(0)).mul(velocity(drumsNow.round().div(2))),
265  riser: s("white*16").hpf(saw.range(400, 8000)).gain(saw.range(.05, .35)).decay(.07).sustain(0).orbit('riser'),
266  dropout: silence,
267}).mask("<0!7 1>")

dropout: the parts stop for the section's last beat. The outro has no next section, so its last beat plays.

270const lastBeat = "<0!7 [0 0 0 1]>"
271const gap = stack(
272  "<1!7 [1 1 1 0]>",
273  into.fmap((c) => c !== 'dropout').mask(lastBeat),
274  section.choice.fmap((s) => s === 'outro').mask(lastBeat),
275)
277stack(parts.mask(gap), transition, form.arrange(once(stack(story, ending))))
278  .mul(postgain(.52)) // the master level: peaks under -1 dBFS (superdough has no limiter)