song.jsannotatedsong.jssource267 lines · 13.5 KB · raw
1// FORTY-FIVE PERCENT — lo-fi hip hop, 80bpm, 48 bars as written; Jev arranges up to 16 sections and it fades out. Spec: SPEC.md
2// Start from STOPPED so the song begins at bar 1 (re-running it mid-play keeps the cycle count).
3setcps(80/60/4)
4await samples('github:tidalcycles/dirt-samples')
5await samples('jev-samples/strudel.json')
6
7// ── Jev: two of them, in TypeSafe's own shape (SPEC.md, "Jev in the booth") ──
8// formJev picks the next section; djJev, asked once that is decided (questions
9// in one request cannot see each other), says how much of each part plays
10// it, how the song moves into it, and how hard. Every 4 bars. The form's
11// rules (walk, at the bottom) narrow the sections Jev may pick and end the
12// song; without Jev it plays as written.
13const song = {
14  title: 'FORTY-FIVE PERCENT',
15  what: 'lo-fi hip hop about a stock-picking bot: on a three-way guess (up, flat or down) it scored 45 percent, better than the 33 percent of a blind guess and worse than an index fund; it fades out on This is fine',
16  tempo: '80 bpm, 16ths swung by a third, 4 bars a section',
17  key: 'D dorian (C major)',
18  chords: 'Dm7 Dm7 Cmaj7 Cmaj7 Fmaj7 Fmaj7 Em7 Em7, two bars each',
19  lyrics: {
20    verse1: 'Forty-five percent / Up, flat, or down',
21    verse1b: 'Better than a coin / Worse than an index fund',
22    verse2: 'A disciplined reasoning layer / Not a trading model',
23    verse2b: 'Don\'t trust the word / Probability',
24    outroEnd: 'This is fine',
25  },
26}
27
28const formJev = jev({
29  state: song,
30  questions: {
31    section: choice('Pick the section of the song that plays next, from those allowed. The outro starts the fade that ends the song: choose it once the song has built to its peak and made its point, before it drags', {
32      intro: 'Rhodes keys with tape wobble, vinyl crackle and the chest-thump hum loop',
33      verse1: 'first half of verse 1, vocoded, over the boom-bap and bass',
34      verse1b: 'second half of verse 1, vocoded',
35      beat: 'the beat alone: the hum loop with Stonks chopped into it, no vocals',
36      beatB: 'the beat alone, second half',
37      verse2: 'first half of verse 2, vocoded',
38      verse2b: 'second half of verse 2, vocoded',
39      beatFull: 'the fuller beat: sixteenth hats, an open hat, a kick variation, the bass up to the fifth',
40      beatFullB: 'the fuller beat, middle',
41      beatFullC: 'the fuller beat, last four bars',
42      outro: 'the drums fall away and the bed starts to fade',
43      outroEnd: 'the bed fades down over the crackle, then This is fine: the end',
44    }),
45  },
46})
47
48// how much of each part the section decidedThisSection has: out, back or full
49const level = (part) => score('How much of ' + 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', [
50  'out: silent for the section',
51  'back: under the rest of the mix',
52  'full: as written',
53])
54const djJev = jev({
55  state: song,
56  questions: {
57    keys: level('the keys (Rhodes chords with tape wobble and the vinyl crackle, the bed under every section as written)'),
58    loop: level('the hum loop (the chest-thump hum\'s thumps on the beat grid, with Stonks chopped into it in the beat sections, from the intro to the outro)'),
59    drums: level('the drums (the swung boom-bap kick, snare and hats, from verse 1 until the outro)'),
60    bass: level('the bass (a round filtered bass locked to the kick, from verse 1 until the outro)'),
61    // the words are always sung: in a verse Jev only sets how far forward
62    vocals: choice('How far forward should the vocals (the vocoded verse lines) sit in the next section (decidedThisSection)? They are always sung', {
63      full: 'full: as written, the words up front',
64      back: 'back: a little under the beat',
65    }),
66    into: choice('How should the song move into the next section (decidedThisSection)? It plays over the last bar of the section before it', {
67      cut: 'straight in, no transition',
68      fill: 'a swung snare fill tumbling into the first beat, in place of the drums for that bar',
69      riser: 'the vinyl crackle thickening into a hiss that swells over the last bar',
70      dropout: 'everything stops for the last beat, a gap before the section comes in',
71    }),
72    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', [
73      'hushed: background music, well under full',
74      'laid back: a little under full',
75      'as written',
76      'up front: louder than written',
77    ]),
78  },
79})
80
81// a little chance in the form: the section is drawn from Jev's probabilities
82const { section } = formJev.every(4, { sample: 0.7 })
83const dj = djJev.every(4, {
84  after: { section },
85  forget: ['into'],
86  // vocals only in the verses: elsewhere there is nothing to ask
87  allow: { vocals: (d) => (/^verse/.test(d.section) ? ['back', 'full'] : ['full']) },
88  fallback: { keys: 2, loop: 2, drums: 2, bass: 2, vocals: 'full', into: 'cut', energy: 2 },
89})
90// A part at Jev's level: silent when out, otherwise its velocity scaled by
91// the level. Each part has its own orbit, named for its question, so the
92// meter hears it alone (measuredSound).
93const part = (name, pat) =>
94  pat.orbit(name).mask(dj[name].score.round().gt(0)).mul(velocity(dj[name].score.round().div(2)))
95// The vocals are never out: back or full
96const vocals = (pat) =>
97  pat.orbit('vocals').mul(velocity(dj.vocals.choice.fmap((c) => (c === 'back' ? .65 : 1))))
98
99// bars: 1-4 intro | 5-12 verse 1 | 13-20 beat | 21-28 verse 2 | 29-40 beat, fuller
100//       41-48 outro: drums fall away, the bed fades        (each mask = 48 steps)
101const verse1  = "<0!4 1!8 0!36>"
102const verse2  = "<0!20 1!8 0!20>"
103const kick    = "<0!4 1!40 0!4>"
104const snare   = "<0!4 1!38 0!6>"
105const hats8   = "<0!4 1!24 0!20>"
106const hats16  = "<0!28 1!14 0!6>"
107const fuller  = "<0!28 1!12 0!8>"
108const bassOn  = "<0!4 1!24 0!12 1!4 0!4>"   // the plain bass line; the fuller beat has its own
109// Stonks never plays in a section's last bar: that bar is the transition's.
110// Chopped into stutters in each beat section's first two bars, the whole
111// word on the last 8th of its third.
112const chops   = "<0!12 1 1 0 0 1 1 0 0 0!8 1 1 0 0 1 1 0 0 1 1 0 0 0!8>"
113const stab    = "<0!12 0 0 1 0 0 0 1 0 0!8 0 0 1 0 0 0 1 0 0 0 1 0 0!8>"
114const humOn   = "<1!44 0!4>"
115const humGain = "<.31!4 .21!8 .31!8 .21!8 .34!12 .24!4 .18!4>"
116// the bed falls to about -12 dB, never to nothing; the crackle is not in it
117const fade    = "<1!40 .85 .72 .6 .5 .4 .32 .25 0>"
118const fine    = "<0!46 1 0>"
119
120// two bars per chord; the cycle starts on Dm7 so both verses open on Fmaj7:
121// Dm7 Dm7 Cmaj7 Cmaj7 Fmaj7 Fmaj7 Em7 Em7   (MIDI numbers)
122const chords = "<[50,53,57,60]!2 [48,52,55,59]!2 [53,57,60,64]!2 [52,55,59,62]!2>"
123const roots  = "<38!2 36!2 41!2 40!2>"   // D2 C2 F2 E2
124const comp   = "x ~ ~ ~ ~ ~ x ~ ~ ~ ~ ~ x ~ ~ ~"
125
126// ── parts ─────────────────────────────────────────
127// Every part is straight here; one swing, 16ths by a third, is applied to
128// the whole band below.
129// ── keys: Rhodes-ish comping; the tape wobble is a slow vibrato ──
130const keys = stack(
131  note(chords).struct(comp)
132    .s("triangle").attack(.01).decay(.9).sustain(.15).release(.4)
133    .lpf(1800).room(.4).gain(.17),
134  note(chords).add(note(12)).struct(comp)
135    .s("sine").decay(.5).sustain(0).gain(.045)
136).vib(.7).vibmod(.1)
137
138// vinyl crackle: sparse, fixed, high-passed noise ticks
139const crackle = s("white").struct("x ~ ~ ~ ~ ~ ~ x ~ ~ ~ ~ x ~ ~ ~ ~ x ~ ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~ ~ ~")
140  .decay(.01).sustain(0).hpf(5000).gain(.068)
141
142// ── percussion loop: the chest-thump hum's thumps, one per slice of ffhum
143// (0 A, 1 E, 2 D, 3 a short thump), on the 8th grid, and Stonks ──
144const loop = stack(
145  s("ffhum").slice(8, "0 ~ 1 ~ 2 ~ 3 1").lpf(3000).gain(humGain).mask(humOn),
146  s("ffstonks").slice(4, "~ ~ ~ ~ ~ [0 0] ~ [0 1 2 3]").clip(1).gain(.28).mask(chops),
147  s("ffstonks").struct("~ ~ ~ ~ ~ ~ ~ x").gain(.28).mask(stab)
148)
149
150// ── drums: dusty boom-bap ──
151const drums = stack(
152  s("bd:4").struct("x ~ ~ ~ ~ ~ ~ x ~ ~ x ~ ~ ~ ~ ~").lpf(3500).gain(.31).mask(kick),
153  // the fuller beat's two-bar kick variation: a pickup into every other bar
154  s("bd:4").struct("<~ [~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ x]>").lpf(3500).gain(.26).mask(fuller),
155  s("sn:2").struct("~ ~ ~ ~ x ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~").lpf(3500).room(.2).gain(.26).mask(snare),
156  s("hh*16").gain(".16 0 .08 0").lpf(6000).mask(hats8),
157  s("hh*16").gain(".14 .056 .1 .056").lpf(6000).mask(hats16),
158  // the fuller beat's open hat on the "and" of 4
159  s("808oh").struct("~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ x ~").lpf(7000).gain(.045).mask(fuller)
160)
161
162// ── bass: locked to the kick, root, root, 5th ──
163const bassSound = (pat) => pat.s("sawtooth").attack(.005).decay(.3).sustain(.5).release(.15)
164  .lpf(700).lpq(2).shape(.3).gain(.26).vib(.7).vibmod(.1)
165const low = stack(
166  bassSound(note("0@7 0@3 7@6").add(note(roots))).mask(bassOn),
167  // the fuller beat: the bar's second half on the 5th, again on the "and" of 4
168  bassSound(note("0@7 0@3 7@4 7@2").add(note(roots))).mask(fuller)
169)
170
171// ── vox: vocoder, one line per 2 bars, each on its chord ──
172// <…> indexes by cycle mod 8; verses start on cycles 4 and 20, so line 1 sits at index 4.
173// "Up, flat, or down" comes in a beat late: on the bar, the downbeat buried its
174// "Up", and an 8th late, the beat-2 snare buried its "flat".
175const lines1 = "<ffcoin ~ ffindex ~ fffortyfive ~ [~ ffupflat@3] ~>"
176const lines2 = "<fftrust ~ ffprob ~ fflayer ~ ffnotmodel ~>"
177// Dry on the word. The echo is a quiet copy of the line a dotted half (2.25 s)
178// after it starts, so it lands after the line ends (the longest is 2.0 s).
179// It is written out, not .delay(): superdough makes its DelayNode with the
180// default one-second maximum, so a longer delaytime plays at 1 s, on the line.
181const sung = (lines, verse) => stack(
182  s(lines).gain(1).room(.3),
183  s(lines).late(3/4).gain(.22).hpf(400).lpf(3000).room(.3)
184).mask(verse)
185const vox = stack(sung(lines1, verse1), sung(lines2, verse2))
186
187// the last word: it always plays, over the faded bed
188const lastWord = s("fffine").gain(.8).room(.3).mask(fine)
189
190// the outro fade (bars 41-47 fall to about -12 dB) multiplies velocity, as
191// Jev's part levels do; Jev's energy below uses postgain
192const faded = (...parts) => stack(...parts).mul(velocity(fade))
193// play once, 48 bars as written; the last notes start on bar 47's third
194// beat, so the song stops about a beat after This is fine
195const ending = "<1!46 [1 1 1 0] 0>"
196const once = (p) => p.mask("<1!48 0!952>").mask(ending)
197
198// ── the form, as rules in code: which sections may follow which ──
199// `at` is where each starts as written; an empty `next` ends the song. The
200// written order is always allowed, and is what plays without Jev.
201// The verse and beat halves are forced together, so a vocal line is never
202// cut; every path to the outro passes through verse 2, so every line is sung.
203const form = walk(section, {
204  max: 16,
205  sections: {
206    intro: { at: 0, next: ['verse1'] },
207    verse1: { at: 4, next: ['verse1b'] },
208    verse1b: { at: 8, next: ['beat'] },
209    beat: { at: 12, next: ['beatB'] },
210    beatB: { at: 16, next: ['verse2', 'verse1'] },
211    verse2: { at: 20, next: ['verse2b'] },
212    verse2b: { at: 24, next: ['beatFull', 'beat'] },
213    beatFull: { at: 28, next: ['beatFullB'] },
214    beatFullB: { at: 32, next: ['beatFullC', 'verse2', 'outro'] },
215    beatFullC: { at: 36, next: ['outro', 'verse2'] },
216    outro: { at: 40, next: ['outroEnd'] },
217    outroEnd: { at: 44, next: [] },
218  },
219})
220
221// ── into the next section: over the last bar of each, the next one's answer ──
222// Past the last section there is no next one, so nothing plays there.
223const into = dj.into.choice.early(1)
224const lastBar = "<0!3 1>"
225// a fill replaces the drums for its bar
226const noFill = stack("<1!3 0>", into.fmap((c) => c !== 'fill').mask(lastBar))
227
228// ── the song: Jev's section, parts and energy, composed ──
229// Each section plays its own bars, each part at the level Jev chose. The
230// fade and the levels both multiply velocity, so neither undoes the other;
231// one swing for the whole band; the last word always plays.
232const parts = form.arrange(once(stack(
233  faded(
234    part('keys', keys),
235    part('loop', loop),
236    part('drums', drums.mask(noFill)),
237    part('bass', low),
238    vocals(vox),
239  ),
240  part('keys', crackle),
241).swingBy(1/3, 8)))
242
243// A transition plays at the level of the part it belongs to in the section
244// it plays in, and fades with it.
245const transition = into.pick({
246  cut: silence,
247  fill: stack(
248    s("bd:4").struct("x ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~ ~").gain(.31),
249    s("sn:2").struct("~ ~ ~ ~ ~ ~ x ~ ~ ~ x ~ x ~ x x").gain(.26)
250  ).lpf(3500).room(.2).orbit('drums')
251    .mask(dj.drums.score.round().gt(0)).mul(velocity(dj.drums.score.round().div(2))),
252  riser: s("white*32").decay(.012).sustain(0).hpf(saw.range(1500, 7000)).lpf(9000)
253    .gain(saw.range(.016, .11)).orbit('keys')
254    .mask(dj.keys.score.round().gt(0)).mul(velocity(dj.keys.score.round().div(2))),
255  dropout: silence,
256}).mask(lastBar).swingBy(1/3, 8).mul(velocity(form.arrange(once(fade))))
257// dropout: the parts stop for the section's last beat
258const lastBeat = "<0!3 [0 0 0 1]>"
259const gap = stack(
260  "<1!3 [1 1 1 0]>",
261  into.fmap((c) => c !== 'dropout').mask(lastBeat),
262)
263
264// postgain scales every part after its own gain and velocity, so the mix
265// and the fade keep their shape; as written (rung 2) is 1
266stack(parts.mask(gap), transition, form.arrange(once(lastWord)))
267  .mul(postgain(dj.energy.score.fmap((e) => [.6, .8, 1, 1.1][Math.round(e)])))