@vosjs/cli 0.72.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,634 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Compose an original score for a vos program from a JSON spec, locked to the
4
+ program's beat grid and arranged section by section.
5
+
6
+ python compose.py spec.json score.wav
7
+
8
+ Writes a 48 kHz stereo float WAV at exactly the program's length (bars x bar
9
+ length, or the spec's `length` in seconds), beat 1 of bar 0 at t = 0. Needs
10
+ numpy and scipy. Normalize and encode it with ffmpeg afterwards (see SKILL.md).
11
+
12
+ The instruments: an 808-style kick with a pitch sweep, a layered gated clap,
13
+ swung 16th hats, a crash, snare rolls, noise risers, a two-filter funk pluck
14
+ bass with a sub under it, an FM electric piano, detuned saw brass, orchestra
15
+ hits, a square/saw lead with vibrato, a pad, a convolution room reverb and a
16
+ bus saturation. Their sounds and the mix are tuned; leave them alone unless
17
+ the genre needs a different instrument, and then add one beside them.
18
+
19
+ The spec decides everything about the piece: tempo, swing, chords (as note
20
+ names or MIDI numbers), the bass riff and the hook (both must be original),
21
+ and the sections, each a texture over a bar range with an energy. See
22
+ `references/new-jack-swing-124.json` and `references/spec.md`.
23
+ """
24
+ import json
25
+ import sys
26
+
27
+ import numpy as np
28
+ from scipy import signal
29
+ from scipy.io import wavfile
30
+
31
+ SR = 48000
32
+
33
+ # The energy each texture was tuned at. A section's `energy` scales the whole
34
+ # texture (drums and parts) by energy / reference, so the reference energy
35
+ # plays the texture exactly as tuned.
36
+ TEXTURE_ENERGY = {
37
+ 'build': 0.65,
38
+ 'groove': 0.72,
39
+ 'stop': 0.75,
40
+ 'pump': 1.0,
41
+ 'drop': 1.05,
42
+ 'dark': 0.8,
43
+ 'chorus': 1.25,
44
+ }
45
+ MOMENTS = ('intro', 'spin', 'ending', 'silence')
46
+ TEXTURES = tuple(TEXTURE_ENERGY) + MOMENTS
47
+
48
+ NOTE_NAMES = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11}
49
+
50
+
51
+ class SpecError(Exception):
52
+ pass
53
+
54
+
55
+ def note(v):
56
+ """A MIDI number from 64, '64', 'E4', 'F#3' or 'Bb2' (C4 = 60)."""
57
+ if isinstance(v, (int, float)) and not isinstance(v, bool):
58
+ return int(v)
59
+ s = str(v).strip()
60
+ if s.lstrip('-').isdigit():
61
+ return int(s)
62
+ if not s or s[0].upper() not in NOTE_NAMES:
63
+ raise SpecError(f'"{v}" is not a note (write E4, F#3, Bb2 or a MIDI number)')
64
+ pc = NOTE_NAMES[s[0].upper()]
65
+ rest = s[1:]
66
+ while rest and rest[0] in '#b':
67
+ pc += 1 if rest[0] == '#' else -1
68
+ rest = rest[1:]
69
+ try:
70
+ octave = int(rest)
71
+ except ValueError:
72
+ raise SpecError(f'"{v}" has no octave (write E4, not E)')
73
+ return 12 * (octave + 1) + pc
74
+
75
+
76
+ # --- the spec -----------------------------------------------------------------
77
+
78
+ def load_spec(path):
79
+ with open(path) as f:
80
+ spec = json.load(f)
81
+ for key in ('bpm', 'bars', 'chords', 'sections'):
82
+ if key not in spec:
83
+ raise SpecError(f'the spec has no "{key}"')
84
+ chords = {}
85
+ for name, c in spec['chords'].items():
86
+ if 'root' not in c or 'voicing' not in c:
87
+ raise SpecError(f'chord {name} needs a "root" (the bass note) and a "voicing" (the comp notes)')
88
+ chords[name] = {'root': note(c['root']), 'voicing': [note(m) for m in c['voicing']]}
89
+ spec['_chords'] = chords
90
+
91
+ bars = spec['bars']
92
+ prev_end = 0
93
+ names = set()
94
+ for i, s in enumerate(spec['sections']):
95
+ label = s.get('name', f'section {i}')
96
+ if label in names:
97
+ raise SpecError(f'two sections are named "{label}"; names must be unique')
98
+ names.add(label)
99
+ s['name'] = label
100
+ tex = s.get('texture')
101
+ if tex not in TEXTURES:
102
+ raise SpecError(f'{label}: texture "{tex}" is not one of {", ".join(TEXTURES)}')
103
+ b0, b1 = s.get('bars', [None, None])
104
+ if b0 is None or b1 is None or not b0 < b1:
105
+ raise SpecError(f'{label}: "bars" must be [start, end] with start < end')
106
+ if b0 != prev_end:
107
+ raise SpecError(f'{label}: starts at bar {b0} but the section before ends at {prev_end} (no gaps, no overlaps)')
108
+ prev_end = b1
109
+ for ch in s.get('chords', []):
110
+ if ch not in chords:
111
+ raise SpecError(f'{label}: chord "{ch}" is not defined in "chords"')
112
+ if tex in TEXTURE_ENERGY or tex == 'intro':
113
+ if not s.get('chords'):
114
+ raise SpecError(f'{label}: a {tex} section needs "chords" (one per bar, cycled)')
115
+ if tex in TEXTURE_ENERGY:
116
+ s.setdefault('energy', TEXTURE_ENERGY[tex])
117
+ if tex in ('drop', 'chorus') and not spec.get('riff'):
118
+ raise SpecError(f'{label}: a {tex} section plays the bass riff, and the spec has no "riff"')
119
+ if tex in ('drop', 'chorus') and not spec.get('hook'):
120
+ raise SpecError(f'{label}: a {tex} section plays the hook, and the spec has no "hook"')
121
+ if tex == 'ending':
122
+ hits = s.get('hits') or []
123
+ if not hits:
124
+ raise SpecError(f'{label}: an ending needs "hits" (at least the last one)')
125
+ for h in hits:
126
+ if h.get('chord') not in chords:
127
+ raise SpecError(f'{label}: hit chord "{h.get("chord")}" is not defined')
128
+ if not b0 <= h['at'] < b1:
129
+ raise SpecError(f'{label}: a hit at bar {h["at"]} is outside the section')
130
+ for key in ('hit',):
131
+ if s.get(key) and s[key] not in chords:
132
+ raise SpecError(f'{label}: "{key}" chord "{s[key]}" is not defined')
133
+ if prev_end != bars:
134
+ raise SpecError(f'the sections end at bar {prev_end} but the piece has {bars} bars')
135
+ for f in spec.get('fills', []):
136
+ if not 0 <= f['bar'] < bars:
137
+ raise SpecError(f'a fill at bar {f["bar"]} is outside the piece')
138
+ return spec
139
+
140
+
141
+ def main():
142
+ if len(sys.argv) != 3:
143
+ print('usage: python compose.py spec.json score.wav', file=sys.stderr)
144
+ sys.exit(2)
145
+ try:
146
+ spec = load_spec(sys.argv[1])
147
+ except SpecError as e:
148
+ print(f'spec: {e}', file=sys.stderr)
149
+ sys.exit(2)
150
+ Score(spec).render(sys.argv[2])
151
+
152
+
153
+ # --- the score ----------------------------------------------------------------
154
+
155
+ class Score:
156
+ def __init__(self, spec):
157
+ self.spec = spec
158
+ self.bpm = float(spec['bpm'])
159
+ self.beat = 60.0 / self.bpm
160
+ self.bar = 4 * self.beat
161
+ self.s16 = self.beat / 4
162
+ self.bars = int(spec['bars'])
163
+ self.length = float(spec.get('length', self.bars * self.bar))
164
+ self.swing = float(spec.get('swing', 0.24))
165
+ self.rng = np.random.default_rng(int(spec.get('seed', 1990)))
166
+ self.ch = {k: v['voicing'] for k, v in spec['_chords'].items()}
167
+ self.root = {k: v['root'] for k, v in spec['_chords'].items()}
168
+ self.riff = [tuple(x) for x in spec.get('riff', [])]
169
+ self.sub = [tuple(x) for x in spec.get('sub', [(0, 12, 4), (8, 12, 2), (14, 12, 2)])]
170
+ self.held = [tuple(x) for x in spec.get('held', [(0, 12, 6), (8, 12, 3), (12, 19, 3)])]
171
+ self.hook_phrases = [[(p, note(m), ln) for p, m, ln in phrase] for phrase in spec.get('hook', [])]
172
+
173
+ n = int(max(self.length, self.bars * self.bar) * SR) + SR * 4
174
+ self.L = np.zeros(n)
175
+ self.R = np.zeros(n)
176
+ self.VL = np.zeros(n) # reverb send
177
+ self.VR = np.zeros(n)
178
+
179
+ # --- time and placement ---------------------------------------------------
180
+
181
+ def at(self, bar, pos16=0.0, swing=True):
182
+ """Seconds for a bar and a 16th position, odd 16ths swung late."""
183
+ p = float(pos16)
184
+ odd = int(round(p)) % 2 == 1 and abs(p - round(p)) < 1e-6
185
+ late = self.swing * self.s16 if swing and odd else 0
186
+ return bar * self.bar + p * self.s16 + late
187
+
188
+ def at_bar(self, x, swing=True):
189
+ """Seconds for a fractional bar position (27.5 = bar 27, 16th 8)."""
190
+ bar = int(np.floor(x + 1e-9))
191
+ return self.at(bar, round((x - bar) * 16, 6), swing)
192
+
193
+ def place(self, sig, t, gain=1.0, pan=0.0, verb=0.0):
194
+ L = self.L
195
+ i = int(round(t * SR))
196
+ if i < 0 or i >= len(L):
197
+ return
198
+ n = min(len(sig), len(L) - i)
199
+ s = sig[:n] * gain
200
+ gl = np.cos((pan + 1) * np.pi / 4)
201
+ gr = np.sin((pan + 1) * np.pi / 4)
202
+ L[i:i + n] += s * gl
203
+ self.R[i:i + n] += s * gr
204
+ if verb:
205
+ self.VL[i:i + n] += s * gl * verb
206
+ self.VR[i:i + n] += s * gr * verb
207
+
208
+ # --- dsp helpers ----------------------------------------------------------
209
+
210
+ @staticmethod
211
+ def tt(dur):
212
+ return np.arange(int(dur * SR)) / SR
213
+
214
+ @staticmethod
215
+ def midi(m):
216
+ return 440.0 * 2 ** ((m - 69) / 12)
217
+
218
+ @staticmethod
219
+ def filt(x, kind, f, order=2):
220
+ if kind == 'bp':
221
+ sos = signal.butter(order, [f[0], f[1]], 'bandpass', fs=SR, output='sos')
222
+ else:
223
+ sos = signal.butter(order, f, kind, fs=SR, output='sos')
224
+ return signal.sosfilt(sos, x)
225
+
226
+ @staticmethod
227
+ def saw(f, t, detune=0.0):
228
+ ph = (f * (1 + detune) * t) % 1.0
229
+ return 2 * ph - 1
230
+
231
+ @staticmethod
232
+ def adsr(n, a=0.005, d=0.1, s=0.6, r=0.08, hold=None):
233
+ a_n, d_n, r_n = int(a * SR), int(d * SR), int(r * SR)
234
+ hold_n = n - a_n - d_n - r_n if hold is None else int(hold * SR)
235
+ hold_n = max(hold_n, 0)
236
+ env = np.concatenate([
237
+ np.linspace(0, 1, max(a_n, 1)),
238
+ np.linspace(1, s, max(d_n, 1)),
239
+ np.full(hold_n, s),
240
+ np.linspace(s, 0, max(r_n, 1)),
241
+ ])
242
+ return env[:n] if len(env) >= n else np.pad(env, (0, n - len(env)))
243
+
244
+ # --- drums ----------------------------------------------------------------
245
+
246
+ def kick(self, v=1.0):
247
+ t = self.tt(0.55)
248
+ f = 44 + 96 * np.exp(-t * 30)
249
+ ph = 2 * np.pi * np.cumsum(f) / SR
250
+ body = np.sin(ph) * np.exp(-t * 6.5)
251
+ click = self.filt(self.rng.standard_normal(len(t)), 'hp', 2500) * np.exp(-t * 400) * 0.35
252
+ return np.tanh((body + click) * 1.6) * v
253
+
254
+ def clap(self, v=1.0):
255
+ t = self.tt(0.32)
256
+ n = self.rng.standard_normal(len(t))
257
+ env = np.zeros(len(t))
258
+ for k, off in enumerate((0.0, 0.009, 0.019)):
259
+ i = int(off * SR)
260
+ env[i:] += np.exp(-(t[: len(t) - i]) * (220 if k < 2 else 18)) * (0.8 if k < 2 else 1.0)
261
+ noise = self.filt(n, 'bp', (900, 5200)) * env
262
+ body = np.sin(2 * np.pi * 195 * t) * np.exp(-t * 32) * 0.4
263
+ return (noise + body) * v
264
+
265
+ def hat(self, v=1.0, open_=False):
266
+ t = self.tt(0.3 if open_ else 0.06)
267
+ n = self.filt(self.rng.standard_normal(len(t)), 'hp', 7200)
268
+ return n * np.exp(-t * (11 if open_ else 75)) * v
269
+
270
+ def crash(self, v=1.0):
271
+ t = self.tt(2.2)
272
+ n = self.filt(self.rng.standard_normal(len(t)), 'hp', 3800)
273
+ return n * np.exp(-t * 2.1) * v
274
+
275
+ def snare(self, v=1.0):
276
+ t = self.tt(0.18)
277
+ n = self.filt(self.rng.standard_normal(len(t)), 'bp', (1500, 7000)) * np.exp(-t * 26)
278
+ body = np.sin(2 * np.pi * 215 * t) * np.exp(-t * 40) * 0.5
279
+ return (n + body) * v
280
+
281
+ def riser(self, bars):
282
+ t = self.tt(bars * self.bar)
283
+ n = self.rng.standard_normal(len(t))
284
+ out = np.zeros_like(n)
285
+ blocks = 48
286
+ edges = np.linspace(0, len(t), blocks + 1).astype(int)
287
+ for b in range(blocks):
288
+ lo = 400 + 7000 * (b / blocks) ** 2
289
+ out[edges[b]:edges[b + 1]] = self.filt(n[edges[b]:edges[b + 1]], 'bp', (lo, lo * 1.6))
290
+ return out * np.linspace(0.05, 1, len(t)) ** 2
291
+
292
+ # --- pitched --------------------------------------------------------------
293
+
294
+ def bass_note(self, m, dur, funky=True, v=1.0):
295
+ t = self.tt(dur + 0.04)
296
+ f = self.midi(m)
297
+ raw = 0.6 * self.saw(f, t) + 0.4 * np.sign(np.sin(2 * np.pi * f * t))
298
+ if funky:
299
+ bright = self.filt(raw, 'lp', 2400)
300
+ dark = self.filt(raw, 'lp', 320)
301
+ c = np.exp(-t * 26)
302
+ tone = c * bright + (1 - c) * dark
303
+ else:
304
+ tone = self.filt(raw, 'lp', 260)
305
+ sub = np.sin(2 * np.pi * f * t) * 0.9
306
+ env = self.adsr(len(t), a=0.003, d=0.08, s=0.7, r=0.03)
307
+ return np.tanh((tone * 0.7 + sub) * env * 1.4) * v
308
+
309
+ def ep_chord(self, ms, dur, v=1.0):
310
+ t = self.tt(dur + 0.3)
311
+ out = np.zeros(len(t))
312
+ for m in ms:
313
+ f = self.midi(m)
314
+ idx = 2.2 * np.exp(-t * 9)
315
+ out += np.sin(2 * np.pi * f * t + idx * np.sin(2 * np.pi * f * t))
316
+ env = self.adsr(len(t), a=0.002, d=0.25, s=0.35, r=0.25, hold=max(dur - 0.25, 0))
317
+ return out * env / len(ms) * v
318
+
319
+ def brass_chord(self, ms, dur, v=1.0, cutoff=3200, fall=False):
320
+ t = self.tt(dur + 0.12)
321
+ out = np.zeros(len(t))
322
+ for m in ms:
323
+ f = self.midi(m)
324
+ if fall:
325
+ f = f * 2 ** (-np.clip((t - dur * 0.55) / (dur * 0.45), 0, 1) * 3 / 12)
326
+ ph = np.cumsum(f) / SR
327
+ for d in (-0.006, 0.0, 0.006):
328
+ out += 2 * ((ph * (1 + d)) % 1.0) - 1
329
+ else:
330
+ for d in (-0.006, 0.0, 0.006):
331
+ out += self.saw(f, t, d)
332
+ out = self.filt(out, 'lp', cutoff)
333
+ env = self.adsr(len(t), a=0.012, d=0.12, s=0.55, r=0.1)
334
+ return np.tanh(out * env / (len(ms) * 2.2) * 2.0) * v
335
+
336
+ def orch_hit(self, ms, v=1.0, ring=0.9):
337
+ t = self.tt(ring)
338
+ out = self.brass_chord(ms + [m + 12 for m in ms], ring - 0.12, v=1.0, cutoff=5200)[: len(t)]
339
+ burst = self.filt(self.rng.standard_normal(len(t)), 'bp', (300, 6000)) * np.exp(-t * 30) * 0.6
340
+ timp = np.sin(2 * np.pi * self.midi(ms[0] - 12) * t) * np.exp(-t * 4) * 0.8
341
+ return (out + burst + timp) * v
342
+
343
+ def lead_note(self, m, dur, v=1.0):
344
+ t = self.tt(dur + 0.06)
345
+ f = self.midi(m) * (1 + 0.004 * np.sin(2 * np.pi * 5.5 * t) * np.clip(t / 0.25, 0, 1))
346
+ ph = np.cumsum(f) / SR
347
+ tone = 0.55 * np.sign(np.sin(2 * np.pi * ph)) + 0.45 * (2 * (ph % 1.0) - 1)
348
+ tone = self.filt(tone, 'lp', 3400)
349
+ env = self.adsr(len(t), a=0.008, d=0.08, s=0.75, r=0.05)
350
+ return tone * env * v
351
+
352
+ def pad(self, ms, dur, v=1.0):
353
+ t = self.tt(dur)
354
+ out = np.zeros(len(t))
355
+ for m in ms:
356
+ for d in (-0.004, 0.004):
357
+ out += self.saw(self.midi(m), t, d)
358
+ out = self.filt(out, 'lp', 1200)
359
+ env = self.adsr(len(t), a=0.4, d=0.1, s=0.9, r=0.5)
360
+ return out * env / (len(ms) * 2) * v
361
+
362
+ # --- phrases ----------------------------------------------------------------
363
+
364
+ def bass_bar(self, bar, chord, pattern, funky=True, v=0.55):
365
+ r = self.root[chord]
366
+ for pos, iv, ln in pattern:
367
+ self.place(self.bass_note(r + iv, ln * self.s16 * 0.92, funky=funky), self.at(bar, pos), v)
368
+
369
+ def ep_bar(self, bar, chord, v=0.2):
370
+ for pos in (2, 6, 10, 14): # the off-beat comp
371
+ self.place(self.ep_chord(self.ch[chord], self.s16 * 1.6), self.at(bar, pos), v, pan=-0.3, verb=0.25)
372
+
373
+ def drums(self, bar, kicks, claps=(4, 12), hats='16', v=1.0, open_hat=(14,), gang=False):
374
+ for p in kicks:
375
+ self.place(self.kick(), self.at(bar, p), 0.95 * v)
376
+ for p in claps:
377
+ self.place(self.clap(), self.at(bar, p), 0.5 * v, pan=0.05, verb=0.35)
378
+ if gang:
379
+ for p in (0, 4, 8, 12):
380
+ self.place(self.clap(), self.at(bar, p), 0.22 * v, pan=-0.25, verb=0.4)
381
+ if hats:
382
+ step = 1 if hats == '16' else 2
383
+ for p in range(0, 16, step):
384
+ if p in open_hat:
385
+ self.place(self.hat(open_=True), self.at(bar, p), 0.16 * v, pan=0.3)
386
+ continue
387
+ acc = 1.0 if p % 4 == 2 else (0.75 if p % 2 == 0 else 0.5)
388
+ self.place(self.hat(), self.at(bar, p), 0.2 * acc * v, pan=0.3)
389
+
390
+ def roll(self, bar, start16, end16, v=0.45):
391
+ """A snare roll on straight 16ths, rising in velocity; may cross bars."""
392
+ n = end16 - start16
393
+ for k, p in enumerate(range(start16, end16)):
394
+ b, q = bar + p // 16, p % 16
395
+ self.place(self.snare(0.3 + 0.7 * k / max(n - 1, 1)), self.at(b, q, swing=False), v, pan=-0.1, verb=0.2)
396
+
397
+ def hook(self, bar, phrase, octave=0, v=0.28, harmony=False):
398
+ for pos, m, ln in self.hook_phrases[phrase % len(self.hook_phrases)]:
399
+ nt = m + 12 * octave
400
+ dur = ln * self.s16 * 0.95
401
+ self.place(self.lead_note(nt, dur), self.at(bar, pos), v, pan=0.15, verb=0.3)
402
+ self.place(self.brass_chord([nt - 12], dur, cutoff=2600), self.at(bar, pos), v * 0.8, pan=-0.15, verb=0.25)
403
+ if harmony:
404
+ self.place(self.lead_note(nt - 5, dur), self.at(bar, pos), v * 0.6, pan=-0.35, verb=0.3)
405
+
406
+ # --- textures -------------------------------------------------------------
407
+ # Each takes a section dict and writes its bars. `g` scales every level in
408
+ # the texture; at the reference energy it is 1 and the texture plays as
409
+ # tuned. Per-section overrides: kicks (a list, or a list of lists cycled
410
+ # per bar), claps, hats ('16', '8' or ''), open_hats.
411
+
412
+ @staticmethod
413
+ def _bars(s):
414
+ return list(range(int(s['bars'][0]), int(np.ceil(s['bars'][1]))))
415
+
416
+ @staticmethod
417
+ def _bp(x):
418
+ """A bar position as (bar, 16th): 27.5 is (27, 8)."""
419
+ bar = int(np.floor(x + 1e-9))
420
+ return bar, int(round((x - bar) * 16))
421
+
422
+ @staticmethod
423
+ def _chord(s, k):
424
+ chords = s['chords']
425
+ return chords[k % len(chords)]
426
+
427
+ @staticmethod
428
+ def _kicks(s, k, default):
429
+ kicks = s.get('kicks')
430
+ if kicks is None:
431
+ return default
432
+ if kicks and isinstance(kicks[0], list):
433
+ return kicks[k % len(kicks)]
434
+ return kicks
435
+
436
+ def _g(self, s):
437
+ return s['energy'] / TEXTURE_ENERGY[s['texture']]
438
+
439
+ def _entry(self, s, big=False, verb=0.3):
440
+ """The crash and the orchestra hit that open a section, when asked."""
441
+ b0 = int(s['bars'][0])
442
+ if s.get('crash'):
443
+ self.place(self.crash(1.2) if big else self.crash(), self.at(b0, 0), s['crash'], verb=verb)
444
+ if s.get('hit'):
445
+ self.place(self.orch_hit(self.ch[s['hit']], ring=0.6), self.at(b0, 0), s.get('hit_level', 0.35), verb=0.4)
446
+
447
+ def tex_intro(self, s):
448
+ """An orchestra hit on the first chord, a riser across the last bar."""
449
+ bars = self._bars(s)
450
+ last = bars[-1]
451
+ self.place(self.orch_hit(self.ch[s['chords'][0]], ring=s.get('ring', 1.8)), self.at(bars[0], 0), 0.55, verb=0.6)
452
+ self.place(self.riser(1.0), self.at(last, 0), 0.12, verb=0.3)
453
+ for p in range(8, 16, 2):
454
+ self.place(self.hat(), self.at(last, p), 0.12, pan=0.3)
455
+ self.place(self.kick(), self.at(last, 12), 0.6)
456
+
457
+ def tex_build(self, s):
458
+ """Low drums, a sub bass, a pad: the groove before it arrives."""
459
+ g = self._g(s)
460
+ for k, b in enumerate(self._bars(s)):
461
+ ch = self._chord(s, k)
462
+ self.drums(b, kicks=self._kicks(s, k, (0, 8)), claps=s.get('claps', (4, 12)), hats=s.get('hats', '8'),
463
+ v=s['energy'], open_hat=s.get('open_hats', (14,)))
464
+ self.bass_bar(b, ch, self.sub, funky=False, v=0.45 * g)
465
+ self.place(self.pad(self.ch[ch], self.bar), self.at(b, 0), 0.18 * g, verb=0.3)
466
+ self._entry(s)
467
+
468
+ def tex_groove(self, s):
469
+ """The main groove: syncopated kick, claps on 2 and 4, the EP comp."""
470
+ g = self._g(s)
471
+ for k, b in enumerate(self._bars(s)):
472
+ ch = self._chord(s, k)
473
+ self.drums(b, kicks=self._kicks(s, k, (0, 6, 8, 11)), claps=s.get('claps', (4, 12)),
474
+ hats=s.get('hats', '8'), v=s['energy'], open_hat=s.get('open_hats', (6, 14)))
475
+ self.bass_bar(b, ch, self.sub, funky=True, v=0.38 * g)
476
+ self.ep_bar(b, ch, v=0.15 * g)
477
+ self._entry(s, verb=0.2)
478
+
479
+ def tex_stop(self, s):
480
+ """Stop-time: the band hits together on `hits` (16ths), silence between."""
481
+ g = self._g(s)
482
+ for k, b in enumerate(self._bars(s)):
483
+ ch = self._chord(s, k)
484
+ for pos in s.get('hits', (0, 8)):
485
+ self.place(self.kick(), self.at(b, pos), 0.95 * g)
486
+ self.place(self.orch_hit(self.ch[ch], ring=0.5), self.at(b, pos), 0.42 * g, verb=0.35)
487
+ self.place(self.bass_note(self.root[ch] + 12, self.s16 * 3, funky=True), self.at(b, pos), 0.5 * g)
488
+ self.place(self.clap(), self.at(b, pos), 0.3 * g, verb=0.4)
489
+ self._entry(s)
490
+
491
+ def tex_pump(self, s):
492
+ """Four on the floor with brass on every beat."""
493
+ g = self._g(s)
494
+ for k, b in enumerate(self._bars(s)):
495
+ ch = self._chord(s, k)
496
+ self.drums(b, kicks=self._kicks(s, k, (0, 4, 8, 12)), claps=s.get('claps', (4, 12)),
497
+ hats=s.get('hats', '8'), v=s['energy'], open_hat=s.get('open_hats', (2, 6, 10, 14)))
498
+ self.bass_bar(b, ch, self.sub, funky=True, v=0.5 * g)
499
+ for pos in (0, 4, 8, 12):
500
+ self.place(self.brass_chord(self.ch[ch], self.s16 * 1.4, cutoff=2400), self.at(b, pos), 0.2 * g, pan=0.2, verb=0.2)
501
+ self._entry(s, verb=0.2)
502
+
503
+ def tex_spin(self, s):
504
+ """A fill section: a snare roll and a riser, the last beat silent."""
505
+ b0, b1 = s['bars']
506
+ bar, p0 = self._bp(b0)
507
+ n16 = int(round((b1 - b0) * 16))
508
+ level = s.get('level', 0.95)
509
+ self.roll(bar, p0, p0 + n16 - 4, v=level)
510
+ self.place(self.riser((n16 - 4) / 16), self.at_bar(b0), 0.42, verb=0.3)
511
+ for p in range(p0, p0 + n16 - 4, 4): # the kick keeps the fill on the grid
512
+ self.place(self.kick(), self.at(bar + p // 16, p % 16), 0.7)
513
+
514
+ def tex_drop(self, s):
515
+ """The biggest groove before the end: the funk riff, the hook, stabs."""
516
+ g = self._g(s)
517
+ for k, b in enumerate(self._bars(s)):
518
+ ch = self._chord(s, k)
519
+ default = (0, 6, 8, 11, 15) if k % 2 else (0, 6, 8, 11)
520
+ self.drums(b, kicks=self._kicks(s, k, default), claps=s.get('claps', (4, 12)), hats=s.get('hats', '16'),
521
+ v=s['energy'], open_hat=s.get('open_hats', (14,)))
522
+ self.bass_bar(b, ch, self.riff, funky=True, v=0.66 * g)
523
+ self.ep_bar(b, ch, v=0.16 * g)
524
+ self.hook(b, k, v=0.34 * g)
525
+ for pos in (2, 10): # brass stabs on the off-beats
526
+ self.place(self.brass_chord(self.ch[ch], self.s16 * 1.2, cutoff=3000), self.at(b, pos), 0.2 * g, pan=0.25, verb=0.2)
527
+ self._entry(s, big=True)
528
+
529
+ def tex_dark(self, s):
530
+ """A darker section: held bass, a pad, a snare on every beat."""
531
+ g = self._g(s)
532
+ bars = self._bars(s)
533
+ for k, b in enumerate(bars):
534
+ ch = self._chord(s, k)
535
+ self.drums(b, kicks=self._kicks(s, k, (0, 8, 10)), claps=s.get('claps', (4, 12)), hats=s.get('hats', '8'),
536
+ v=s['energy'], open_hat=s.get('open_hats', ()))
537
+ self.bass_bar(b, ch, self.held, funky=False, v=0.5 * g)
538
+ if k % 2 == 0:
539
+ span = min(2, len(bars) - k)
540
+ self.place(self.pad(self.ch[ch], span * self.bar), self.at(b, 0), 0.32 * g, verb=0.45)
541
+ for pos in (0, 4, 8, 12):
542
+ self.place(self.snare(0.4), self.at(b, pos), 0.12 * g, pan=-0.2, verb=0.3)
543
+ self._entry(s)
544
+
545
+ def tex_chorus(self, s):
546
+ """Everything: the hook an octave up with a harmony, gang claps."""
547
+ g = self._g(s)
548
+ for k, b in enumerate(self._bars(s)):
549
+ ch = self._chord(s, k)
550
+ self.drums(b, kicks=self._kicks(s, k, (0, 6, 8, 11, 15)), claps=s.get('claps', (4, 12)),
551
+ hats=s.get('hats', '16'), v=s['energy'], open_hat=s.get('open_hats', (14,)), gang=True)
552
+ self.bass_bar(b, ch, self.riff, funky=True, v=0.8 * g)
553
+ self.ep_bar(b, ch, v=0.2 * g)
554
+ self.hook(b, k, octave=s.get('octave', 1), v=0.38 * g, harmony=True)
555
+ self.place(self.pad(self.ch[ch], self.bar), self.at(b, 0), 0.3 * g, verb=0.4)
556
+ for pos in (2, 10):
557
+ self.place(self.brass_chord(self.ch[ch], self.s16 * 1.2, cutoff=3400), self.at(b, pos), 0.24 * g, pan=0.25, verb=0.2)
558
+ for p in range(0, 16):
559
+ self.place(self.hat(0.6), self.at(b, p), 0.07 * g, pan=-0.4) # tambourine-ish 16ths
560
+ self._entry(s, big=True)
561
+
562
+ def tex_ending(self, s):
563
+ """A pickup roll, hits, and the last hit ringing out to the end."""
564
+ b0 = s['bars'][0]
565
+ pickup = s.get('pickup', 0)
566
+ if pickup:
567
+ bar, p0 = self._bp(b0)
568
+ self.roll(bar, p0, p0 + int(round(pickup * 16)), v=0.95)
569
+ self.place(self.riser(pickup), self.at_bar(b0), 0.35, verb=0.3)
570
+ hits = sorted(s['hits'], key=lambda h: h['at'])
571
+ for h in hits[:-1]:
572
+ t = self.at_bar(h['at'])
573
+ self.place(self.orch_hit(self.ch[h['chord']], ring=0.45), t, 0.4, verb=0.4)
574
+ self.place(self.kick(), t, 0.9)
575
+ last = hits[-1]
576
+ t = self.at_bar(last['at'])
577
+ ch = last['chord']
578
+ self.place(self.kick(), t, 1.0)
579
+ self.place(self.orch_hit(self.ch[ch], ring=last.get('ring', 3.6)), t, 0.6, verb=0.7)
580
+ self.place(self.brass_chord(self.ch[ch], 1.6, fall=True), t, 0.25, verb=0.5)
581
+ self.place(self.bass_note(self.root[ch] + 12, 2.5, funky=False), t, 0.5)
582
+ self.place(self.crash(1.3), t, 0.4, verb=0.4)
583
+ for b in range(int(np.floor(last['at'])) + 1, int(np.ceil(s['bars'][1]))):
584
+ for p in range(0, 16, 2): # the hats carry the room out under the fade
585
+ self.place(self.hat(0.5), self.at(b, p), 0.08, pan=0.3)
586
+
587
+ def tex_silence(self, s):
588
+ pass
589
+
590
+ # --- render ---------------------------------------------------------------
591
+
592
+ def arrange(self):
593
+ fills = self.spec.get('fills', [])
594
+ for s in self.spec['sections']:
595
+ getattr(self, 'tex_' + s['texture'])(s)
596
+ b0, b1 = s['bars']
597
+ for f in fills: # a fill belongs to the section it sits in
598
+ if b0 <= f['bar'] < b1:
599
+ self.roll(int(f['bar']), int(f.get('from', 12)), int(f.get('to', 16)), v=f.get('level', 0.6))
600
+
601
+ def render(self, path):
602
+ self.arrange()
603
+ rng = self.rng
604
+ ir_t = self.tt(1.6)
605
+ irL = rng.standard_normal(len(ir_t)) * np.exp(-ir_t * 3.2)
606
+ irR = rng.standard_normal(len(ir_t)) * np.exp(-ir_t * 3.2)
607
+ irL = self.filt(irL, 'lp', 6000)
608
+ irR = self.filt(irR, 'lp', 6000)
609
+ irL /= np.sqrt(np.sum(irL ** 2))
610
+ irR /= np.sqrt(np.sum(irR ** 2))
611
+ wetL = signal.fftconvolve(self.VL, irL)[: len(self.L)]
612
+ wetR = signal.fftconvolve(self.VR, irR)[: len(self.R)]
613
+ mixL = self.L + wetL * 0.5
614
+ mixR = self.R + wetR * 0.5
615
+
616
+ # Glue: a gentle bus saturation, then cut to the program's length exactly.
617
+ n = int(round(self.length * SR))
618
+ mix = np.stack([mixL[:n], mixR[:n]], axis=1)
619
+ mix = self.filt(mix.T, 'hp', 28).T
620
+ peak = np.max(np.abs(mix))
621
+ mix = np.tanh(mix / peak * 1.4) / np.tanh(1.4)
622
+ mix *= 0.89
623
+ wavfile.write(path, SR, mix.astype(np.float32))
624
+
625
+ print(f'wrote {path}: {n / SR:.4f} s, {self.bars} bars at {self.bpm:g} BPM (bar = {self.bar:.4f} s)')
626
+ print('section map (bars, seconds):')
627
+ for s in self.spec['sections']:
628
+ b0, b1 = s['bars']
629
+ energy = f' energy {s["energy"]:g}' if 'energy' in s else ''
630
+ print(f' {b0:>5g} to {b1:<5g} {b0 * self.bar:7.3f} to {b1 * self.bar:7.3f} s {s["texture"]:<8} {s["name"]}{energy}')
631
+
632
+
633
+ if __name__ == '__main__':
634
+ main()