|
|
@@ -0,0 +1,182 @@
|
|
|
+"""Synthesises RedCore's two music loops and writes them as Ogg Vorbis.
|
|
|
+
|
|
|
+ python3 tools/make_music.py # needs numpy and ffmpeg
|
|
|
+
|
|
|
+menu.ogg: calm, 90 BPM, soft pad and plucked arpeggio.
|
|
|
+game.ogg: driving, 120 BPM, drums, bass, arpeggio and a simple lead.
|
|
|
+Both use the chords Am - F - C - G and are an exact number of bars long; echo tails
|
|
|
+wrap around to the start, so they loop without a seam.
|
|
|
+"""
|
|
|
+import pathlib
|
|
|
+import subprocess
|
|
|
+import tempfile
|
|
|
+import wave
|
|
|
+
|
|
|
+import numpy as np
|
|
|
+
|
|
|
+RATE = 44100
|
|
|
+OUT = pathlib.Path(__file__).resolve().parent.parent / "assets" / "music"
|
|
|
+
|
|
|
+# Chord roots and their triads as MIDI notes (A minor, F, C, G).
|
|
|
+CHORDS = [(57, [57, 60, 64]), (53, [53, 57, 60]), (48, [48, 52, 55]), (55, [55, 59, 62])]
|
|
|
+
|
|
|
+
|
|
|
+def freq(midi):
|
|
|
+ return 440.0 * 2 ** ((midi - 69) / 12)
|
|
|
+
|
|
|
+
|
|
|
+def envelope(n, attack, decay, sustain, release):
|
|
|
+ """ADSR envelope over n samples; times in seconds, sustain as a level."""
|
|
|
+ a, d, r = (int(x * RATE) for x in (attack, decay, release))
|
|
|
+ env = np.full(n, sustain)
|
|
|
+ a = min(a, n)
|
|
|
+ env[:a] = np.linspace(0, 1, a, endpoint=False)
|
|
|
+ d = min(d, n - a)
|
|
|
+ env[a:a + d] = np.linspace(1, sustain, d, endpoint=False)
|
|
|
+ r = min(r, n)
|
|
|
+ env[n - r:] *= np.linspace(1, 0, r)
|
|
|
+ return env
|
|
|
+
|
|
|
+
|
|
|
+def tone(midi, seconds, harmonics=(1.0,), detune=0.0):
|
|
|
+ """Additive oscillator: a weighted sum of harmonics, optionally two detuned voices."""
|
|
|
+ t = np.arange(int(seconds * RATE)) / RATE
|
|
|
+ f = freq(midi)
|
|
|
+ out = np.zeros_like(t)
|
|
|
+ for k, weight in enumerate(harmonics, start=1):
|
|
|
+ out += weight * np.sin(2 * np.pi * f * k * t)
|
|
|
+ if detune:
|
|
|
+ out += weight * np.sin(2 * np.pi * f * k * (1 + detune) * t)
|
|
|
+ return out / (sum(harmonics) * (2 if detune else 1))
|
|
|
+
|
|
|
+
|
|
|
+def add(track, sound, at):
|
|
|
+ """Mixes sound into track at sample offset at, wrapping past the end (seamless loop)."""
|
|
|
+ n = len(track)
|
|
|
+ start = int(at) % n
|
|
|
+ end = start + len(sound)
|
|
|
+ if end <= n:
|
|
|
+ track[start:end] += sound
|
|
|
+ else:
|
|
|
+ track[start:] += sound[:n - start]
|
|
|
+ track[:end - n] += sound[n - start:]
|
|
|
+
|
|
|
+
|
|
|
+def echo(track, delay, feedback, mix):
|
|
|
+ """Circular feedback delay, so the tail lands back at the loop start."""
|
|
|
+ out = track.copy()
|
|
|
+ shift = int(delay * RATE)
|
|
|
+ tap = track.copy()
|
|
|
+ for _ in range(6):
|
|
|
+ tap = np.roll(tap, shift) * feedback
|
|
|
+ out += tap * mix
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def lowpass(signal, cutoff):
|
|
|
+ """One-pole low-pass filter, run twice for a gentler 12 dB/octave slope."""
|
|
|
+ alpha = 1 - np.exp(-2 * np.pi * cutoff / RATE)
|
|
|
+ out = signal
|
|
|
+ for _ in range(2):
|
|
|
+ y = np.empty_like(out)
|
|
|
+ acc = 0.0
|
|
|
+ for i, x in enumerate(out):
|
|
|
+ acc += alpha * (x - acc)
|
|
|
+ y[i] = acc
|
|
|
+ out = y
|
|
|
+ return out
|
|
|
+
|
|
|
+
|
|
|
+def kick(seconds=0.35):
|
|
|
+ t = np.arange(int(seconds * RATE)) / RATE
|
|
|
+ pitch = 50 + 90 * np.exp(-t * 30)
|
|
|
+ fade = np.minimum(1.0, (seconds - t) / 0.05) # no click where it ends
|
|
|
+ return np.sin(2 * np.pi * np.cumsum(pitch) / RATE) * np.exp(-t * 9) * fade
|
|
|
+
|
|
|
+
|
|
|
+def noise_hit(seconds, decay, low, high, rng):
|
|
|
+ """A decaying noise burst band-limited to [low, high] Hz."""
|
|
|
+ n = int(seconds * RATE)
|
|
|
+ hit = rng.uniform(-1, 1, n) * np.exp(-np.arange(n) / RATE * decay)
|
|
|
+ band = lowpass(hit, high)
|
|
|
+ return band - lowpass(band, low)
|
|
|
+
|
|
|
+
|
|
|
+def menu_track():
|
|
|
+ bpm, bars = 90, 8
|
|
|
+ beat = 60 / bpm
|
|
|
+ track = np.zeros(int(bars * 4 * beat * RATE))
|
|
|
+ for bar in range(bars):
|
|
|
+ root, triad = CHORDS[(bar // 2) % 4]
|
|
|
+ start = bar * 4 * beat * RATE
|
|
|
+ if bar % 2 == 0: # a pad chord held for two bars
|
|
|
+ for note in triad:
|
|
|
+ pad = tone(note, 8 * beat, (1.0, 0.3, 0.1), detune=0.004)
|
|
|
+ add(track, 0.10 * pad * envelope(len(pad), 1.2, 0.5, 0.8, 1.5), start)
|
|
|
+ bass = tone(root - 12, 8 * beat, (1.0, 0.2))
|
|
|
+ add(track, 0.16 * bass * envelope(len(bass), 0.3, 1.0, 0.6, 1.0), start)
|
|
|
+ arp = triad + [triad[1] + 12, triad[2] + 12, triad[1] + 12]
|
|
|
+ for i in range(8): # plucked eighth notes
|
|
|
+ note = arp[i % len(arp)] + 12
|
|
|
+ pluck = tone(note, 1.2, (1.0, 0.5, 0.25, 0.1))
|
|
|
+ add(track, 0.07 * pluck * envelope(len(pluck), 0.005, 0.4, 0.0, 0.3), start + i * beat / 2 * RATE)
|
|
|
+ return echo(track, beat * 0.75, 0.45, 0.35)
|
|
|
+
|
|
|
+
|
|
|
+def game_track():
|
|
|
+ bpm, bars = 120, 16
|
|
|
+ beat = 60 / bpm
|
|
|
+ rng = np.random.default_rng(7)
|
|
|
+ track = np.zeros(int(bars * 4 * beat * RATE))
|
|
|
+ drums = np.zeros_like(track)
|
|
|
+ hat = noise_hit(0.06, 70, 5000, 9000, rng)
|
|
|
+ snare = noise_hit(0.22, 20, 900, 5000, rng)
|
|
|
+ lead_notes = [76, 74, 72, 74, 76, 79, 76, 74] # one phrase, transposed per chord
|
|
|
+ for bar in range(bars):
|
|
|
+ root, triad = CHORDS[bar % 4]
|
|
|
+ start = bar * 4 * beat * RATE
|
|
|
+ for b in range(4):
|
|
|
+ add(drums, 0.40 * kick(), start + b * beat * RATE)
|
|
|
+ if b in (1, 3):
|
|
|
+ add(drums, 0.35 * snare, start + b * beat * RATE)
|
|
|
+ for e in range(8):
|
|
|
+ add(drums, (0.12 if e % 2 else 0.07) * hat, start + e * beat / 2 * RATE)
|
|
|
+ bass = tone(root - 12, beat / 2, (1.0, 0.6, 0.3, 0.15))
|
|
|
+ add(track, 0.30 * bass * envelope(len(bass), 0.005, 0.1, 0.6, 0.05), start + e * beat / 2 * RATE)
|
|
|
+ for note in triad: # a quiet pad for body
|
|
|
+ pad = tone(note, 4 * beat, (1.0, 0.3, 0.1), detune=0.004)
|
|
|
+ add(track, 0.05 * pad * envelope(len(pad), 0.3, 0.5, 0.7, 0.4), start)
|
|
|
+ arp = triad + [triad[1] + 12]
|
|
|
+ for s in range(16):
|
|
|
+ note = arp[s % len(arp)] + 12
|
|
|
+ pluck = tone(note, beat / 2, (1.0, 0.0, 0.33, 0.0, 0.2)) # square-ish
|
|
|
+ add(track, 0.08 * pluck * envelope(len(pluck), 0.003, 0.08, 0.0, 0.05), start + s * beat / 4 * RATE)
|
|
|
+ if bar >= 8: # the lead joins for the second half
|
|
|
+ shift = root - 57
|
|
|
+ for i, note in enumerate(lead_notes):
|
|
|
+ lead = tone(note + shift, beat / 2, (1.0, 0.4, 0.2), detune=0.003)
|
|
|
+ add(track, 0.12 * lead * envelope(len(lead), 0.01, 0.1, 0.7, 0.08), start + i * beat / 2 * RATE)
|
|
|
+ return echo(track, beat * 0.75, 0.3, 0.25) + 0.8 * drums
|
|
|
+
|
|
|
+
|
|
|
+def write_ogg(signal, name):
|
|
|
+ # Gentle stereo: the same mix with a tiny delay on the right channel.
|
|
|
+ right = np.roll(signal, int(0.012 * RATE))
|
|
|
+ stereo = np.stack([signal, right], axis=1)
|
|
|
+ stereo /= np.max(np.abs(stereo)) / 0.89 # about -1 dBFS
|
|
|
+ pcm = (stereo * 32767).astype(np.int16)
|
|
|
+ OUT.mkdir(parents=True, exist_ok=True)
|
|
|
+ with tempfile.NamedTemporaryFile(suffix=".wav") as tmp:
|
|
|
+ with wave.open(tmp.name, "wb") as w:
|
|
|
+ w.setnchannels(2)
|
|
|
+ w.setsampwidth(2)
|
|
|
+ w.setframerate(RATE)
|
|
|
+ w.writeframes(pcm.tobytes())
|
|
|
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", tmp.name,
|
|
|
+ "-c:a", "libvorbis", "-q:a", "4", str(OUT / name)], check=True)
|
|
|
+ print(f"{name}: {len(signal) / RATE:.1f} s")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ write_ogg(menu_track(), "menu.ogg")
|
|
|
+ write_ogg(game_track(), "game.ogg")
|