make_music.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Synthesises RedCore's two music loops and writes them as Ogg Vorbis.
  2. python3 tools/make_music.py # needs numpy and ffmpeg
  3. menu.ogg: calm, 90 BPM, soft pad and plucked arpeggio.
  4. game.ogg: driving, 120 BPM, drums, bass, arpeggio and a simple lead.
  5. Both use the chords Am - F - C - G and are an exact number of bars long; echo tails
  6. wrap around to the start, so they loop without a seam.
  7. """
  8. import pathlib
  9. import subprocess
  10. import tempfile
  11. import wave
  12. import numpy as np
  13. RATE = 44100
  14. OUT = pathlib.Path(__file__).resolve().parent.parent / "assets" / "music"
  15. # Chord roots and their triads as MIDI notes (A minor, F, C, G).
  16. CHORDS = [(57, [57, 60, 64]), (53, [53, 57, 60]), (48, [48, 52, 55]), (55, [55, 59, 62])]
  17. def freq(midi):
  18. return 440.0 * 2 ** ((midi - 69) / 12)
  19. def envelope(n, attack, decay, sustain, release):
  20. """ADSR envelope over n samples; times in seconds, sustain as a level."""
  21. a, d, r = (int(x * RATE) for x in (attack, decay, release))
  22. env = np.full(n, sustain)
  23. a = min(a, n)
  24. env[:a] = np.linspace(0, 1, a, endpoint=False)
  25. d = min(d, n - a)
  26. env[a:a + d] = np.linspace(1, sustain, d, endpoint=False)
  27. r = min(r, n)
  28. env[n - r:] *= np.linspace(1, 0, r)
  29. return env
  30. def tone(midi, seconds, harmonics=(1.0,), detune=0.0):
  31. """Additive oscillator: a weighted sum of harmonics, optionally two detuned voices."""
  32. t = np.arange(int(seconds * RATE)) / RATE
  33. f = freq(midi)
  34. out = np.zeros_like(t)
  35. for k, weight in enumerate(harmonics, start=1):
  36. out += weight * np.sin(2 * np.pi * f * k * t)
  37. if detune:
  38. out += weight * np.sin(2 * np.pi * f * k * (1 + detune) * t)
  39. return out / (sum(harmonics) * (2 if detune else 1))
  40. def add(track, sound, at):
  41. """Mixes sound into track at sample offset at, wrapping past the end (seamless loop)."""
  42. n = len(track)
  43. start = int(at) % n
  44. end = start + len(sound)
  45. if end <= n:
  46. track[start:end] += sound
  47. else:
  48. track[start:] += sound[:n - start]
  49. track[:end - n] += sound[n - start:]
  50. def echo(track, delay, feedback, mix):
  51. """Circular feedback delay, so the tail lands back at the loop start."""
  52. out = track.copy()
  53. shift = int(delay * RATE)
  54. tap = track.copy()
  55. for _ in range(6):
  56. tap = np.roll(tap, shift) * feedback
  57. out += tap * mix
  58. return out
  59. def lowpass(signal, cutoff):
  60. """One-pole low-pass filter, run twice for a gentler 12 dB/octave slope."""
  61. alpha = 1 - np.exp(-2 * np.pi * cutoff / RATE)
  62. out = signal
  63. for _ in range(2):
  64. y = np.empty_like(out)
  65. acc = 0.0
  66. for i, x in enumerate(out):
  67. acc += alpha * (x - acc)
  68. y[i] = acc
  69. out = y
  70. return out
  71. def kick(seconds=0.35):
  72. t = np.arange(int(seconds * RATE)) / RATE
  73. pitch = 50 + 90 * np.exp(-t * 30)
  74. fade = np.minimum(1.0, (seconds - t) / 0.05) # no click where it ends
  75. return np.sin(2 * np.pi * np.cumsum(pitch) / RATE) * np.exp(-t * 9) * fade
  76. def noise_hit(seconds, decay, low, high, rng):
  77. """A decaying noise burst band-limited to [low, high] Hz."""
  78. n = int(seconds * RATE)
  79. hit = rng.uniform(-1, 1, n) * np.exp(-np.arange(n) / RATE * decay)
  80. band = lowpass(hit, high)
  81. return band - lowpass(band, low)
  82. def menu_track():
  83. bpm, bars = 90, 8
  84. beat = 60 / bpm
  85. track = np.zeros(int(bars * 4 * beat * RATE))
  86. for bar in range(bars):
  87. root, triad = CHORDS[(bar // 2) % 4]
  88. start = bar * 4 * beat * RATE
  89. if bar % 2 == 0: # a pad chord held for two bars
  90. for note in triad:
  91. pad = tone(note, 8 * beat, (1.0, 0.3, 0.1), detune=0.004)
  92. add(track, 0.10 * pad * envelope(len(pad), 1.2, 0.5, 0.8, 1.5), start)
  93. bass = tone(root - 12, 8 * beat, (1.0, 0.2))
  94. add(track, 0.16 * bass * envelope(len(bass), 0.3, 1.0, 0.6, 1.0), start)
  95. arp = triad + [triad[1] + 12, triad[2] + 12, triad[1] + 12]
  96. for i in range(8): # plucked eighth notes
  97. note = arp[i % len(arp)] + 12
  98. pluck = tone(note, 1.2, (1.0, 0.5, 0.25, 0.1))
  99. add(track, 0.07 * pluck * envelope(len(pluck), 0.005, 0.4, 0.0, 0.3), start + i * beat / 2 * RATE)
  100. return echo(track, beat * 0.75, 0.45, 0.35)
  101. def game_track():
  102. bpm, bars = 120, 16
  103. beat = 60 / bpm
  104. rng = np.random.default_rng(7)
  105. track = np.zeros(int(bars * 4 * beat * RATE))
  106. drums = np.zeros_like(track)
  107. hat = noise_hit(0.06, 70, 5000, 9000, rng)
  108. snare = noise_hit(0.22, 20, 900, 5000, rng)
  109. lead_notes = [76, 74, 72, 74, 76, 79, 76, 74] # one phrase, transposed per chord
  110. for bar in range(bars):
  111. root, triad = CHORDS[bar % 4]
  112. start = bar * 4 * beat * RATE
  113. for b in range(4):
  114. add(drums, 0.40 * kick(), start + b * beat * RATE)
  115. if b in (1, 3):
  116. add(drums, 0.35 * snare, start + b * beat * RATE)
  117. for e in range(8):
  118. add(drums, (0.12 if e % 2 else 0.07) * hat, start + e * beat / 2 * RATE)
  119. bass = tone(root - 12, beat / 2, (1.0, 0.6, 0.3, 0.15))
  120. add(track, 0.30 * bass * envelope(len(bass), 0.005, 0.1, 0.6, 0.05), start + e * beat / 2 * RATE)
  121. for note in triad: # a quiet pad for body
  122. pad = tone(note, 4 * beat, (1.0, 0.3, 0.1), detune=0.004)
  123. add(track, 0.05 * pad * envelope(len(pad), 0.3, 0.5, 0.7, 0.4), start)
  124. arp = triad + [triad[1] + 12]
  125. for s in range(16):
  126. note = arp[s % len(arp)] + 12
  127. pluck = tone(note, beat / 2, (1.0, 0.0, 0.33, 0.0, 0.2)) # square-ish
  128. add(track, 0.08 * pluck * envelope(len(pluck), 0.003, 0.08, 0.0, 0.05), start + s * beat / 4 * RATE)
  129. if bar >= 8: # the lead joins for the second half
  130. shift = root - 57
  131. for i, note in enumerate(lead_notes):
  132. lead = tone(note + shift, beat / 2, (1.0, 0.4, 0.2), detune=0.003)
  133. add(track, 0.12 * lead * envelope(len(lead), 0.01, 0.1, 0.7, 0.08), start + i * beat / 2 * RATE)
  134. return echo(track, beat * 0.75, 0.3, 0.25) + 0.8 * drums
  135. def write_ogg(signal, name):
  136. # Gentle stereo: the same mix with a tiny delay on the right channel.
  137. right = np.roll(signal, int(0.012 * RATE))
  138. stereo = np.stack([signal, right], axis=1)
  139. stereo /= np.max(np.abs(stereo)) / 0.89 # about -1 dBFS
  140. pcm = (stereo * 32767).astype(np.int16)
  141. OUT.mkdir(parents=True, exist_ok=True)
  142. with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
  143. with wave.open(tmp.name, "wb") as w:
  144. w.setnchannels(2)
  145. w.setsampwidth(2)
  146. w.setframerate(RATE)
  147. w.writeframes(pcm.tobytes())
  148. subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", tmp.name,
  149. "-c:a", "libvorbis", "-q:a", "4", str(OUT / name)], check=True)
  150. print(f"{name}: {len(signal) / RATE:.1f} s")
  151. if __name__ == "__main__":
  152. write_ogg(menu_track(), "menu.ogg")
  153. write_ogg(game_track(), "game.ogg")