| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182 |
- """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")
|