song.jsannotatedsong.jssource270 lines · 13.4 KB · raw
1// YOU'VE GOT JEV — 2-step garage, 132bpm, 64 bars as written; Jev arranges up to 24 four-bar sections. Spec: SPEC.md
2// Start from STOPPED so the song begins at bar 1 (re-running it mid-play keeps the cycle count).
3setcps(132/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 it,
10// how the song moves into it, and how hard. Every 4 bars. The form's rules (walk, at the bottom) narrow the
11// sections Jev may pick and end the song; without Jev it plays as written.
12const song = {
13  title: 'YOU’VE GOT JEV',
14  what: '2-step garage about Jev routing support mail: AOL dials in, a ticket arrives, and Jev routes it in a blink with no human in the loop',
15  tempo: '132 bpm, 4/4, swung, 4 bars a section',
16  key: 'C minor',
17  chords: 'Cm9 – Fm9 – Bbmaj7 – Abmaj7, two bars each (an 8-bar cycle), under the stabs and the sub',
18  lyrics: {
19    verse1a: 'New ticket in / Billing, bug, or refund',
20    verse1b: 'Choice: billing / Point nine three',
21    hook1: 'You’ve got mail / You’ve got Jev / You’ve got mail / You’ve got Jev',
22    verse2a: 'No human in the loop / Routed in a blink',
23    verse2b: 'Nobody had to read it / You’ve got Jev',
24    hook2: 'You’ve got mail / You’ve got Jev / You’ve got mail / You’ve got Jev',
25    outro: 'Goodbye',
26  },
27}
28
29const formJev = jev({
30  state: song,
31  questions: {
32    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', {
33      modem: 'a dial-up modem alone: the dial tone, the number dialed, the answer tone',
34      handshake: 'the modem handshake screech chopped into a rhythm, hats and muffled stabs coming in',
35      verse1a: 'verse 1, first half: 2-step drums, swung stabs, the warbly sub, vocoded lines',
36      verse1b: 'verse 1, second half',
37      hook1: 'the hook: AOL asks and the vocoder answers',
38      drop1a: 'drop 1: instrumental, open hi-hats, a crash',
39      drop1b: 'drop 1 continues',
40      drop1c: 'drop 1, last four bars',
41      verse2a: 'verse 2, first half',
42      verse2b: 'verse 2, second half',
43      hook2: 'the hook again, into the big drop',
44      drop2a: 'drop 2, the peak: the screech chops return over the full groove, a crash',
45      drop2b: 'drop 2 continues',
46      drop2c: 'drop 2 continues',
47      drop2d: 'drop 2, last four bars',
48      outro: 'a last hit and a ringing chord, then AOL says goodbye: the end',
49    }),
50  },
51})
52
53// how much of each part the section decidedThisSection has: out, back or full
54const 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', [
55  'out: silent for the section',
56  'back: under the rest of the mix',
57  'full: as written',
58])
59const djJev = jev({
60  state: song,
61  questions: {
62    modem: level('the modem (a dial-up modem dialing in alone for the first four bars, the handshake screech chopped into a rhythm in the next four, and the chops again over drop 2)'),
63    drums: level('the drums (the 2-step kick and snare from verse 1 on, swung hi-hats from bar 5, open hats on the off-beats and crashes in the drops)'),
64    stabs: level('the stabs (short swung organ-style chord stabs, muffled at the end of the handshake, then from verse 1 to the outro)'),
65    sub: level('the sub (the warbly sub bass on the chord roots, the floor from verse 1 to the outro)'),
66    vocals: level('the vocals (the vocoded ticket lines in the verses, and AOL asking and the vocoder answering in the hooks; the drops are instrumental as written)'),
67    into: choice('How should the song move into the next section (decidedThisSection)? It plays over the last bar of the section before it', {
68      cut: 'straight in, no transition',
69      fill: 'a snare and clap roll in sixteenths, swelling into the downbeat, over the last half bar',
70      riser: 'the modem handshake screech, chopped into sixteenths, its filter sweeping up over the last half bar',
71      dropout: 'everything stops for the last beat, a gap before the section hits',
72    }),
73    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', [
74      'held back: quieter',
75      'steady: under full',
76      'driving: nearly full',
77      'all out: full volume',
78    ]),
79  },
80})
81
82// a little chance in the form: the section is drawn from Jev's probabilities
83const { section } = formJev.every(4, { sample: 0.7 })
84const dj = djJev.every(4, {
85  after: { section },
86  forget: ['into'],
87  // the intro builds from the modem alone, so the handshake always cuts in
88  allow: { into: ({ section: next }) => (next === 'handshake' ? ['cut'] : ['cut', 'fill', 'riser', 'dropout']) },
89  fallback: { modem: 2, drums: 2, stabs: 2, sub: 2, vocals: 2, into: 'cut', energy: 3 },
90})
91
92// Jev's most probable level. A score's probabilities are keyed by level,
93// "0", "1", … (docs.typesafe.ai/primitives/score); a fallback has none, so
94// it plays the level its score names.
95const mostLikely = (p, score) => {
96  const levels = Object.entries(p ?? {})
97  return levels.length ? Number(levels.reduce((a, b) => (b[1] > a[1] ? b : a))[0]) : Math.round(score)
98}
99const fromAnswer = (answer, f) => answer.score.fmap((score) => (p) => f(p ?? {}, score)).appLeft(answer.probabilities)
100// A part's gain: silent when "out" is Jev's most probable level; otherwise
101// Jev's probability-weighted mix of back (0.5) and full (1).
102const partGain = (p, score) => {
103  const top = mostLikely(p, score)
104  if (top === 0) return 0
105  const back = p['1'] ?? 0, full = p['2'] ?? 0
106  return back + full > 0 ? (back * .5 + full) / (back + full) : top === 1 ? .5 : 1
107}
108// A part at Jev's level. Each part has its own orbit, named for its
109// question, so the meter hears it alone (measuredSound).
110const part = (name, pat) => {
111  const gain = fromAnswer(dj[name], partGain)
112  return pat.orbit(name).mask(gain.gt(0)).mul(velocity(gain))
113}
114
115// bars: 1-4 modem | 5-8 chopped handshake | 9-16 verse 1 | 17-20 hook | 21-32 DROP 1
116//       33-40 verse 2 | 41-44 hook | 45-60 DROP 2 | 61-64 outro      (each mask = 64 steps)
117const modemOn = "<1 0!63>"
118const chopIn  = "<0!4 1!4 0!56>"
119const stabIn  = "<0!6 1!2 0!56>"
120const groove  = "<0!8 1!52 0!4>"
121const verse1  = "<0!8 1!8 0!48>"
122const verse2  = "<0!32 1!8 0!24>"
123const hooks   = "<0!16 1!4 0!20 1!4 0!20>"
124const drops   = "<0!20 1!12 0!12 1!16 0!4>"
125const drop2   = "<0!44 1!16 0!4>"
126const crash   = "<0!20 1 0!23 1 0!19>"
127const lastHit = "<0!60 1 0!3>"
128const bye     = "<0!61 1 0!2>"
129
130// two bars per chord: Cm9 Cm9 Fm9 Fm9 Bbmaj7 Bbmaj7 Abmaj7 Abmaj7, voiced
131// close so the stabs move by step (each keeps the pitch classes its vocoded
132// lines were rendered on)
133const cm9    = "[c3,eb3,g3,bb3,d4]"
134const fm9    = "[f3,g3,ab3,c4,eb4]"
135const bbmaj7 = "[bb2,f3,a3,d4]"
136const abmaj7 = "[ab2,eb3,g3,c4]"
137const chords = cat(cm9, cm9, fm9, fm9, bbmaj7, bbmaj7, abmaj7, abmaj7)   // a chord a bar
138const roots  = "<36!2 29!2 34!2 32!2>"   // C2 F1 Bb1 Ab1, as MIDI numbers
139const chops  = "<[0 ~ 4 ~ 8 2 ~ 12] [~ 6 ~ 14 0 ~ 10 3]>"
140
141// ── parts ─────────────────────────────────────────
142const modem = stack(
143  // bars 1-4: the modem dialing in, alone (first 7.27 s of 7.45)
144  s("modem").end(.976).gain(.9).mask(modemOn),
145  // the handshake screech, chopped into a fixed rhythm
146  s("screech").slice(16, chops).hpf(300).gain(.55).mask(chopIn),
147  // drop 2: the chops return at the intro's level
148  s("screech").slice(16, chops).hpf(300).gain(.55).pan(.3).mask(drop2)
149)
150
151// drums: 2-step
152const drums = stack(
153  s("[bd:3,808bd:1]").struct("x ~ ~ ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~ ~ ~").gain(.4).mask(groove),
154  s("[sn:3,cp]").struct("~ ~ ~ ~ x ~ ~ ~ ~ ~ ~ ~ x ~ ~ ~").room(.2).gain(.3).mask(groove),
155  s("sn:1").struct("~ ~ ~ ~ ~ ~ ~ x ~ ~ ~ ~ ~ ~ ~ x").swingBy(1/3, 8).gain(.1).mask(groove),
156  s("hh*16").gain(".32 .12 .22 .12").swingBy(1/3, 8).mask("<0!4 1!56 0!4>"),
157  // open hats on the off-beats in the drops
158  s("808oh").struct("~ x ~ x ~ x ~ x").gain(.2).mask(drops),
159  // hh27:1 is a crash; `cr` is a ride (songs/CLAUDE.md)
160  s("hh27:1").gain(1).mask(crash)
161)
162
163// organ-style stabs, short and swung
164const stabs = stack(
165  // the filter opens in the drops: 3000 in drop 1, 4000 in drop 2
166  note(chords).struct("~ ~ x ~ ~ x ~ ~ ~ x ~ ~ x ~ ~ ~").swingBy(1/3, 8)
167    .s("sawtooth").lpf("<1700!20 3000!12 1700!12 4000!16 1700!4>").decay(.16).sustain(0).room(.3).gain(.45).mask(groove),
168  note(chords).struct("~ ~ x ~ ~ x ~ ~ ~ x ~ ~ x ~ ~ ~").swingBy(1/3, 8)
169    .s("sawtooth").lpf(600).decay(.16).sustain(0).gain(.12).mask(stabIn)
170)
171
172// warbly sub: garage push rhythm, a slow vibrato a fifth of a semitone deep.
173// subClipped is the same line with the note on step 10 cut to two steps, for
174// a section's last bar when a dropout follows, so the sub stops for the gap.
175const subIn = (rhythm) => note(roots).struct(rhythm)
176  .s("sine").vib(.5).vibmod(.2).release(.02).gain(.7).mask(groove)
177const sub = subIn("x@6 x@4 x@6")
178const subClipped = subIn("x@6 x@4 x@2 ~@4")
179
180const vox = stack(
181  // verses: vocoder, one line per 2 bars, each sung on its chord
182  s("<newticket ~ billingbug ~ choicebilling ~ ninetythree ~>").gain(1.25).room(.2).mask(verse1),
183  s("<nohuman ~ routedblink ~ nobodyread ~ gotjevab ~>").gain("<1.25!4 1.5!2 1.25!2>").room(.2).mask(verse2),
184  // hook: AOL asks, the vocoder answers on the hook's chords
185  s("<mail gotjevcm mail gotjevfm>").gain(1.35).room(.25).mask(hooks)
186)
187
188// the end: the last hit, a ringing Cm9 over a C2 sub, and AOL's goodbye. It always plays.
189const ending = stack(
190  s("[bd:3,808bd:1]").room(.5).gain(.4).mask(lastHit),
191  s("hh27:1").room(.5).gain(1).mask(lastHit),
192  note(cm9).s("sawtooth").lpf(1400).decay(1.5).sustain(0).room(.6).gain(.45).mask(lastHit),
193  note("c2").s("sine").vib(.5).vibmod(.2).release(.3).gain(.7).mask(lastHit),
194  s("goodbye").gain(1.2).room(.5).mask(bye)
195)
196
197const once = (p) => p.mask("<1!64 0!936>")   // play once, then silence
198
199// ── the form, as rules in code: which sections may follow which ──
200// `at` is where each starts as written; an empty `next` ends the song. The
201// written order is always allowed, and is what plays without Jev. Nothing
202// leads back into a hook, so each plays once; and every move goes to a
203// section starting 4 bars off in the 8-bar chord cycle, so the two halves of
204// the progression alternate.
205// `times`: each drop's loop goes round once more at most (SPEC.md, "Loops end").
206const form = walk(section, {
207  max: 24,
208  sections: {
209    modem: { at: 0, next: ['handshake'] },
210    handshake: { at: 4, next: ['verse1a'] },
211    verse1a: { at: 8, next: ['verse1b'] },
212    verse1b: { at: 12, next: ['hook1'] },
213    hook1: { at: 16, next: ['drop1a', 'drop1c'] },
214    drop1a: { at: 20, next: ['drop1b'] },
215    drop1b: { at: 24, next: ['drop1c'], times: 2 },
216    drop1c: { at: 28, next: ['verse2a', 'drop1b'] },
217    verse2a: { at: 32, next: ['verse2b'] },
218    verse2b: { at: 36, next: ['hook2'] },
219    hook2: { at: 40, next: ['drop2a'] },
220    drop2a: { at: 44, next: ['drop2b'], times: 2 },
221    drop2b: { at: 48, next: ['drop2c', 'outro'], times: 2 },
222    drop2c: { at: 52, next: ['drop2d', 'drop2b'] },
223    drop2d: { at: 56, next: ['outro', 'drop2a'] },
224    outro: { at: 60, next: [] },
225  },
226})
227
228// ── into the next section: over the last bar of each, the next one's answer ──
229// Past the last section there is no next one, so nothing plays there.
230const into = dj.into.choice.early(1)
231const lastBar = "<0!3 1>"
232// fill and riser build over the second half of the bar, clear of the line
233// on its downbeat
234const transition = into.pick({
235  cut: silence,
236  fill: s("~ [sn:3,cp]*8").room(.2).gain(saw.fast(2).range(.12, .34)).orbit('drums'),
237  riser: s("screech").slice(16, "~ [8 9 10 11 12 13 14 15]")
238    .hpf(saw.fast(2).range(300, 5000)).gain(saw.fast(2).range(.15, .45)).orbit('modem'),
239  dropout: silence,
240}).mask("<0!3 [0 1]>")
241// dropout: the parts stop for the section's last beat
242const lastBeat = "<0!3 [0 0 0 1]>"
243const gap = stack(
244  "<1!3 [1 1 1 0]>",
245  into.fmap((c) => c !== 'dropout').mask(lastBeat),
246)
247// the sub's last bar: clipped when a dropout follows, so it stops for the gap
248const dropoutBar = into.fmap((c) => c === 'dropout').mask(lastBar)
249const notDropoutBar = stack("<1!3 0>", into.fmap((c) => c !== 'dropout').mask(lastBar))
250
251// ── the song: Jev's section, parts and energy, composed ──
252// Each section plays its own bars, each part at the level Jev chose; the
253// energy, Jev's most probable step, scales every part's velocity together,
254// so the mix keeps its balance. The ending always plays.
255const parts = form.arrange(once(stack(
256  part('modem', modem),
257  part('drums', drums),
258  part('stabs', stabs),
259  part('vocals', vox),
260)))
261const subPart = stack(
262  form.arrange(once(part('sub', sub))).mask(notDropoutBar),
263  form.arrange(once(part('sub', subClipped))).mask(dropoutBar),
264)
265const energy = fromAnswer(dj.energy, mostLikely)
266
267// postgain: the whole mix's level, so it peaks under −1 dBFS (SPEC.md, Style: Mix)
268stack(parts.mask(gap), subPart.mask(gap), transition, form.arrange(once(ending)))
269  .mul(velocity(energy.div(3).range(.55, 1)))
270  .postgain(.52)