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