Files
Emberwild/tools/audio/make_audio.py

796 lines
32 KiB
Python

#!/usr/bin/env python3
"""Emberwild's own sounds: the music, the ambience beds and the sound effects
that the Kenney CC0 packs don't cover, all synthesised here (no samples, no
third-party audio), so they're ours to release under CC0 too.
python3 tools/audio/make_audio.py # everything
python3 tools/audio/make_audio.py music # or: music | ambience | sfx | voices
python3 tools/audio/make_audio.py --kenney ~/Downloads/kenney # also re-copy the Kenney files
python3 tools/audio/make_audio.py birds # or crickets: one recorded loop, from tools/audio/sources
Writes .ogg files under assets/audio/ (via ffmpeg). Deterministic: the same
seeds give the same files. Pure Python (no numpy), so it takes a minute or two.
Music (assets/audio/music/): calm (day), night, storm, danger, boss. Each is
a seamless loop: the tail of the last bar is folded back onto the start.
"""
import math, os, random, shutil, struct, subprocess, sys, tempfile, wave
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
OUT = os.path.join(ROOT, "assets", "audio")
TAU = 2.0 * math.pi
sin, exp = math.sin, math.exp
def midi(m):
return 440.0 * 2.0 ** ((m - 69) / 12.0)
# --- writing -----------------------------------------------------------------------
def _encode(path, chans, sr, q=4):
"""chans: [left, right] or [mono] float lists. Peak-normalised, soft-clipped."""
peak = max(max(abs(x) for x in c) for c in chans) or 1.0
g = 0.89 / peak
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
with wave.open(tmp, "wb") as w:
w.setnchannels(len(chans))
w.setsampwidth(2)
w.setframerate(sr)
frames = bytearray()
n = len(chans[0])
for i in range(n):
for c in chans:
v = math.tanh(c[i] * g * 1.05) / math.tanh(1.05)
frames += struct.pack("<h", int(max(-1.0, min(1.0, v)) * 32000))
w.writeframes(bytes(frames))
os.makedirs(os.path.dirname(path), exist_ok=True)
# (ffmpeg's own Vorbis encoder only writes stereo: a mono sound gets two
# identical channels, which Godot plays the same.)
subprocess.run(["ffmpeg", "-v", "error", "-y", "-i", tmp, "-ac", "2", "-c:a", "vorbis", "-strict", "-2",
"-q:a", str(q), path], check=True)
os.remove(tmp)
print(" %s (%.1f s, %d KB)" % (os.path.relpath(path, ROOT), n / sr, os.path.getsize(path) // 1024))
# --- voices (each adds into a buffer at a start time) ---------------------------------
def add_pluck(buf, sr, t0, freq, amp, decay=2.2, harm=(1.0, 0.45, 0.22, 0.1), dur=None):
"""A soft plucked string / harp-ish note."""
i0 = int(t0 * sr)
dur = dur or min(4.5, 7.0 / decay)
n = int(dur * sr)
ph = [0.0] * len(harm)
inc = [TAU * freq * (k + 1) / sr for k in range(len(harm))]
dec = [exp(-decay * (1.0 + 0.6 * k) / sr) for k in range(len(harm))]
env = [h * amp for h in harm]
L = len(buf)
att = int(0.004 * sr)
for j in range(n):
s = 0.0
for k in range(len(harm)):
s += env[k] * sin(ph[k])
ph[k] += inc[k]
env[k] *= dec[k]
if j < att:
s *= j / att
buf[(i0 + j) % L] += s
def add_pad(buf, sr, t0, dur, freqs, amp, attack=1.4, release=1.8, bright=0.25, detune=0.003, lp=None):
"""A slow, warm chord: two detuned sines per note plus a little 2nd harmonic."""
i0 = int(t0 * sr)
n = int((dur + release) * sr)
L = len(buf)
voices = []
for f in freqs:
for d in (-detune, detune):
voices.append([0.0, TAU * f * (1 + d) / sr, 0.0, TAU * 2 * f * (1 + d) / sr])
a_n, d_n = attack * sr, dur * sr
r_n = release * sr
y = 0.0
for j in range(n):
if j < a_n:
e = j / a_n
elif j < d_n:
e = 1.0
else:
e = max(0.0, 1.0 - (j - d_n) / r_n)
e = e * e * (3 - 2 * e)
s = 0.0
for v in voices:
s += sin(v[0]) + bright * sin(v[2])
v[0] += v[1]
v[2] += v[3]
s *= amp * e / len(voices)
if lp:
y += lp * (s - y)
s = y
buf[(i0 + j) % L] += s
def add_saw(buf, sr, t0, dur, freq, amp, lp=0.08, attack=0.01, release=0.15, trem=0.0, trem_hz=6.0):
"""A filtered saw: the ostinato, low strings and brass."""
i0 = int(t0 * sr)
n = int((dur + release) * sr)
L = len(buf)
ph, inc = 0.0, freq / sr
y1 = y2 = 0.0
a_n, d_n, r_n = max(1, attack * sr), dur * sr, max(1, release * sr)
for j in range(n):
if j < a_n:
e = j / a_n
elif j < d_n:
e = 1.0
else:
e = max(0.0, 1.0 - (j - d_n) / r_n)
if trem:
e *= 1.0 - trem * (0.5 + 0.5 * sin(TAU * trem_hz * j / sr))
s = 2.0 * ph - 1.0
ph += inc
if ph >= 1.0:
ph -= 1.0
y1 += lp * (s - y1)
y2 += lp * (y1 - y2)
buf[(i0 + j) % L] += y2 * amp * e
def add_drum(buf, sr, t0, f0, amp, drop=1.6, fall=28.0, decay=8.0, noise=0.15, dur=0.7, rng=None):
"""Toms, kicks and timpani: a pitch-dropping sine with a noisy start."""
i0 = int(t0 * sr)
n = int(dur * sr)
L = len(buf)
ph = 0.0
y = 0.0
rng = rng or random.Random(int(t0 * 1000))
for j in range(n):
t = j / sr
f = f0 * (1.0 + drop * exp(-t * fall))
ph += TAU * f / sr
s = sin(ph) * exp(-t * decay)
y += 0.3 * ((rng.random() * 2 - 1) - y)
s += noise * y * exp(-t * 30.0)
buf[(i0 + j) % L] += s * amp
def add_noise(buf, sr, t0, dur, amp, lp=0.2, hp=0.0, env=None, rng=None):
"""Filtered noise with an envelope function env(k) for k in 0..1."""
i0 = int(t0 * sr)
n = int(dur * sr)
L = len(buf)
rng = rng or random.Random(int(t0 * 977) + 3)
y = z = 0.0
for j in range(n):
x = rng.random() * 2 - 1
y += lp * (x - y)
s = y
if hp:
z += hp * (s - z)
s = s - z
e = env(j / n) if env else 1.0
buf[(i0 + j) % L] += s * amp * e
def echo(src, sr, delay, fb, mix, loops=2):
"""A circular (loop-safe) feedback delay; returns the wet signal."""
L = len(src)
d = int(delay * sr)
wet = [0.0] * L
for _ in range(loops):
for i in range(L):
wet[i] = src[(i - d) % L] * mix + wet[(i - d) % L] * fb
return wet
def lowpass(src, a):
y = 0.0
out = [0.0] * len(src)
for _ in range(2): # (twice round, so the loop point has no click)
for i in range(len(src)):
y += a * (src[i] - y)
out[i] = y
return out
# --- the music ---------------------------------------------------------------------
def _mix(sr, L, dry_l, dry_r, send, d_l=0.37, d_r=0.29, fb=0.38, mix=0.5):
wl = echo(send, sr, d_l, fb, mix)
wr = echo(send, sr, d_r, fb, mix)
return ([dry_l[i] + send[i] + wl[i] for i in range(L)],
[dry_r[i] + send[i] + wr[i] for i in range(L)])
def track_calm():
"""Day: D major, 72 bpm, a warm pad, soft bass, and a sparse harp melody."""
sr, bpm, bars = 22050, 72, 16
beat = 60.0 / bpm
L = int(bars * 4 * beat * sr)
rng = random.Random(11)
pad, bass, mel = [0.0] * L, [0.0] * L, [0.0] * L
# D, Bm, G, A, D, F#m, G, A: two bars each.
chords = [(62, 66, 69), (59, 62, 66), (55, 59, 62), (57, 61, 64),
(62, 66, 69), (54, 57, 61), (55, 59, 62), (57, 61, 64)]
roots = [38, 35, 43, 45, 38, 42, 43, 45]
scale = [74, 76, 78, 81, 83, 86, 88] # D pentatonic, upper
for c, ch in enumerate(chords):
t = c * 8 * beat
add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.55, attack=1.8, release=2.2, lp=0.18)
add_pluck(bass, sr, t, midi(roots[c]), 0.30, decay=0.9, harm=(1.0, 0.25))
add_pluck(bass, sr, t + 4 * beat, midi(roots[c]), 0.22, decay=0.9, harm=(1.0, 0.25))
prev = 3
for b in range(8):
if (c % 2 == 1 and b >= 6) or rng.random() > 0.55:
continue
prev = max(0, min(len(scale) - 1, prev + rng.choice([-2, -1, -1, 1, 1, 2])))
m = scale[prev]
off = rng.choice([0.0, 0.0, 0.5]) * beat
add_pluck(mel, sr, t + b * beat + off, midi(m), 0.26 + rng.random() * 0.08, decay=2.0)
if rng.random() < 0.18:
add_pluck(mel, sr, t + b * beat + off + 0.5 * beat, midi(m + 12), 0.1, decay=3.0,
harm=(1.0, 0.1, 0.05))
dry = [pad[i] + bass[i] for i in range(L)]
return sr, _mix(sr, L, dry, dry, mel)
def track_night():
"""Night: A minor, 56 bpm, dark pad, a low drone and a few distant notes."""
sr, bpm, bars = 22050, 56, 12
beat = 60.0 / bpm
L = int(bars * 4 * beat * sr)
rng = random.Random(23)
pad, low, mel = [0.0] * L, [0.0] * L, [0.0] * L
chords = [(57, 60, 64), (53, 57, 60), (48, 52, 55), (55, 59, 62), (57, 60, 64), (52, 55, 59)]
for c, ch in enumerate(chords):
t = c * 8 * beat
add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.5, attack=2.5, release=3.0, bright=0.1, lp=0.08)
add_pad(low, sr, 0.0, L / sr - 3.0, [midi(33), midi(45)], 0.35, attack=3.0, release=3.0, bright=0.05, detune=0.001)
scale = [69, 72, 74, 76, 79, 81] # A minor pentatonic
t = 1.5 * beat
while t < L / sr - 2.0:
m = rng.choice(scale)
add_pluck(mel, sr, t, midi(m), 0.18, decay=1.4, harm=(1.0, 0.2, 0.06))
if rng.random() < 0.3:
add_pluck(mel, sr, t + beat, midi(rng.choice(scale) - 12), 0.14, decay=1.2)
t += beat * rng.choice([3, 4, 4, 5, 6])
dry = [pad[i] + low[i] for i in range(L)]
return sr, _mix(sr, L, dry, dry, mel, d_l=0.53, d_r=0.41, fb=0.45, mix=0.55)
def track_storm():
"""Storm, blizzard, the red moon: D minor, slow, tremolo low strings, rolls."""
sr, bpm, bars = 22050, 60, 12
beat = 60.0 / bpm
L = int(bars * 4 * beat * sr)
rng = random.Random(37)
pad, strings, drums, wind = [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L
chords = [(50, 53, 57), (46, 50, 53), (43, 46, 50), (45, 49, 52), (50, 53, 57), (46, 50, 55)]
roots = [26, 22, 31, 33, 26, 22]
for c, ch in enumerate(chords):
t = c * 8 * beat
add_pad(pad, sr, t, 8 * beat, [midi(m) for m in ch], 0.45, attack=2.0, release=2.0, bright=0.2, lp=0.12)
add_saw(strings, sr, t, 8 * beat - 0.2, midi(roots[c] + 12), 0.5, lp=0.05, attack=1.0, release=1.0,
trem=0.6, trem_hz=7.0)
add_saw(strings, sr, t, 8 * beat - 0.2, midi(roots[c]), 0.35, lp=0.03, attack=1.5, release=1.0)
# a timpani roll swelling into each second chord
if c % 2 == 1:
n = 22
for k in range(n):
tt = t + 6 * beat + k * (2 * beat / n)
add_drum(drums, sr, tt, midi(roots[c] + 12) * 0.98, 0.1 + 0.5 * (k / n) ** 2, drop=0.2,
fall=20, decay=6, noise=0.2, dur=0.5, rng=rng)
add_drum(drums, sr, t, midi(roots[c] + 12), 0.8, drop=0.3, fall=18, decay=3.0, dur=1.6, rng=rng)
add_noise(wind, sr, 0.0, L / sr, 0.25, lp=0.02,
env=lambda k: 0.6 + 0.4 * sin(TAU * 3 * k) * sin(TAU * 5 * k + 1.0))
dry = [pad[i] + strings[i] + drums[i] + wind[i] for i in range(L)]
send = [0.25 * strings[i] for i in range(L)]
return sr, _mix(sr, L, dry, dry, send, fb=0.3, mix=0.4)
def _action(bpm, bars, seed, boss):
sr = 22050
beat = 60.0 / bpm
L = int(bars * 4 * beat * sr)
rng = random.Random(seed)
pad, ost, drums, brass, fx = [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L, [0.0] * L
# E minor: Em, C, D, B (with D#), then Em, Am, C, B.
prog = [(40, (52, 55, 59)), (36, (48, 52, 55)), (38, (50, 54, 57)), (35, (47, 51, 54)),
(40, (52, 55, 59)), (33, (45, 48, 52)), (36, (48, 52, 55)), (35, (47, 51, 54))]
figure = [0, 0, 12, 0, 7, 0, 10, 7] if not boss else [0, 12, 0, 7, 0, 12, 10, 7]
for bar in range(bars):
root, ch = prog[bar % len(prog)]
t = bar * 4 * beat
build = bar >= bars - 4
add_pad(pad, sr, t, 4 * beat, [midi(m) for m in ch], 0.35, attack=0.3, release=0.6, bright=0.3, lp=0.15)
step = beat / 2
for k in range(8):
m = root + 12 + figure[k]
add_saw(ost, sr, t + k * step, step * 0.7, midi(m), 0.42 if k % 2 == 0 else 0.3,
lp=0.09 if boss else 0.07, attack=0.004, release=0.06)
# drums: low tom on 1 and 3, a higher one on the "and" of 4, shaker 8ths
add_drum(drums, sr, t, 70, 0.9, drop=1.2, decay=7, rng=rng)
add_drum(drums, sr, t + 2 * beat, 70, 0.75, drop=1.2, decay=7, rng=rng)
add_drum(drums, sr, t + 3.5 * beat, 105, 0.55, drop=1.0, decay=9, rng=rng)
if boss:
for b in range(4):
add_drum(drums, sr, t + b * beat, 48, 0.8, drop=2.5, fall=35, decay=10, noise=0.05, rng=rng)
if build or (boss and bar % 4 == 3):
for k in range(8):
add_drum(drums, sr, t + 2 * beat + k * beat / 4, 120 + k * 8, 0.25 + k * 0.05, decay=10, rng=rng)
for k in range(8):
add_noise(drums, sr, t + k * step, 0.06, 0.12 if k % 2 else 0.06, lp=0.9, hp=0.4,
env=lambda q: exp(-q * 6), rng=rng)
if boss:
for m in ch:
add_saw(brass, sr, t, 4 * beat - 0.1, midi(m), 0.22, lp=0.06, attack=0.25, release=0.3)
if bar % 4 == 3:
add_noise(fx, sr, t, 4 * beat, 0.35, lp=0.6, hp=0.2, env=lambda q: q ** 3, rng=rng)
dry = [pad[i] + ost[i] + drums[i] + brass[i] + fx[i] for i in range(L)]
send = [0.2 * ost[i] + 0.3 * brass[i] for i in range(L)]
return sr, _mix(sr, L, dry, dry, send, d_l=beat * 0.75, d_r=beat * 0.5, fb=0.25, mix=0.35)
def track_danger():
"""Wolves at the fire, a bear, a fight: E minor, 104 bpm, ostinato and toms."""
return _action(104, 20, 41, False)
def track_boss():
"""The Old Stag: the danger theme, faster, with brass, kicks and swells."""
return _action(116, 24, 53, True)
# --- ambience ---------------------------------------------------------------------
def amb_fire():
sr, secs = 22050, 6
L = sr * secs
rng = random.Random(83)
b = [0.0] * L
add_noise(b, sr, 0, secs, 0.35, lp=0.015, env=lambda k: 0.8 + 0.2 * sin(TAU * 4 * k))
add_noise(b, sr, 0, secs, 0.06, lp=0.3, hp=0.1, env=lambda k: 0.6 + 0.4 * sin(TAU * 7 * k + 2))
t = 0.0
while t < secs:
a = rng.uniform(0.2, 0.9) if rng.random() < 0.85 else rng.uniform(1.0, 1.6)
add_noise(b, sr, t, rng.uniform(0.004, 0.02), a, lp=0.8, hp=0.3, env=lambda q: exp(-q * 5), rng=rng)
t += rng.expovariate(9.0)
return sr, (b,)
# --- sound effects ------------------------------------------------------------------
SR_FX = 44100
def fx_buf(secs):
return [0.0] * int(secs * SR_FX)
def fx_whoosh(seed, secs=0.32, f0=0.04, f1=0.25, amp=1.0):
rng = random.Random(seed)
b = fx_buf(secs + 0.05)
y = z = 0.0
n = int(secs * SR_FX)
for j in range(n):
q = j / n
a = f0 + (f1 - f0) * sin(math.pi * q)
x = rng.random() * 2 - 1
y += a * (x - y)
z += 0.03 * (y - z)
b[j] = (y - z) * sin(math.pi * q) ** 1.5 * amp
return b
def fx_crunch(seed):
rng = random.Random(seed)
b = fx_buf(0.5)
t = 0.0
for k in range(rng.randint(3, 4)):
d = rng.uniform(0.05, 0.09)
add_noise(b, SR_FX, t, d, rng.uniform(0.7, 1.0), lp=rng.uniform(0.35, 0.7), hp=0.05,
env=lambda q: exp(-q * 7) * min(1.0, q * 40), rng=rng)
for _ in range(6):
add_noise(b, SR_FX, t + rng.uniform(0, d), 0.004, rng.uniform(0.4, 0.9), lp=0.9, hp=0.3,
env=lambda q: 1 - q, rng=rng)
t += d + rng.uniform(0.04, 0.07)
return b
def fx_splash(seed, big=1.0):
rng = random.Random(seed)
b = fx_buf(0.9)
add_noise(b, SR_FX, 0.0, 0.7, 0.9 * big, lp=0.25, hp=0.02, env=lambda q: exp(-q * 5) * min(1, q * 60), rng=rng)
add_noise(b, SR_FX, 0.0, 0.25, 0.5 * big, lp=0.7, hp=0.2, env=lambda q: exp(-q * 9), rng=rng)
for _ in range(int(10 * big)):
t = rng.uniform(0.05, 0.6)
f = rng.uniform(380, 1100)
nn = int(0.05 * SR_FX)
i0 = int(t * SR_FX)
ph = 0.0
for j in range(nn):
q = j / nn
ph += TAU * f * (1 + 0.8 * q) / SR_FX
if i0 + j < len(b):
b[i0 + j] += sin(ph) * sin(math.pi * q) * 0.12
return b
def fx_plop():
b = fx_buf(0.3)
ph = 0.0
n = int(0.16 * SR_FX)
for j in range(n):
q = j / n
ph += TAU * (650 - 430 * q) / SR_FX
b[j] = sin(ph) * exp(-q * 4) * min(1, q * 80) * 0.9
add_noise(b, SR_FX, 0.0, 0.12, 0.25, lp=0.4, hp=0.1, env=lambda q: exp(-q * 8))
return b
def fx_reel():
rng = random.Random(97)
b = fx_buf(0.9)
for k in range(16):
t = k * 0.048 + rng.uniform(0, 0.004)
add_noise(b, SR_FX, t, 0.006, 0.8, lp=0.9, hp=0.4, env=lambda q: 1 - q, rng=rng)
i0 = int(t * SR_FX)
for j in range(int(0.012 * SR_FX)):
b[i0 + j] += sin(TAU * 2900 * j / SR_FX) * exp(-j / 90) * 0.35
return b
def fx_cast():
b = fx_whoosh(101, secs=0.45, f0=0.05, f1=0.3)
ph = 0.0
n = int(0.6 * SR_FX)
out = b + [0.0] * (n - len(b) + 10)
for j in range(n):
q = j / n
ph += TAU * (2400 - 1200 * q) / SR_FX
out[j] += sin(ph) * 0.05 * sin(math.pi * q)
return out
def fx_bow():
b = fx_buf(0.6)
add_noise(b, SR_FX, 0.0, 0.03, 0.6, lp=0.6, hp=0.2, env=lambda q: 1 - q)
ph = [0.0, 0.0, 0.0]
n = int(0.5 * SR_FX)
for j in range(n):
t = j / SR_FX
f = 118 * (1 + 0.04 * sin(TAU * 18 * t) * exp(-t * 8))
s = 0.0
for k, h in enumerate((1.0, 0.5, 0.3)):
ph[k] += TAU * f * (k + 1) / SR_FX
s += h * sin(ph[k])
b[j] += s * exp(-t * 9) * 0.6
return b
def fx_thump(seed):
rng = random.Random(seed)
b = fx_buf(0.45)
ph = 0.0
n = len(b)
for j in range(n):
t = j / SR_FX
ph += TAU * (85 + 70 * exp(-t * 30)) / SR_FX
b[j] = sin(ph) * exp(-t * 11) * 0.9
add_noise(b, SR_FX, 0.0, 0.12, 0.4, lp=0.08, env=lambda q: exp(-q * 6), rng=rng)
return b
def fx_death():
b = fx_buf(1.6)
ph = [0.0, 0.0]
n = len(b)
for j in range(n):
t = j / SR_FX
ph[0] += TAU * (60 + 40 * exp(-t * 4)) / SR_FX
ph[1] += TAU * (220 * 2 ** (-t * 1.2)) / SR_FX
b[j] = sin(ph[0]) * exp(-t * 2.5) * 0.8 + sin(ph[1]) * exp(-t * 2.0) * 0.3 * min(1, t * 20)
return b
# --- creature voices -----------------------------------------------------------------
# A buzzy source (a saw with a wandering pitch, roughened by fast amplitude
# flutter and breath noise) through a few resonant "throat" filters.
def bandpass(src, sr, f, q, gain=1.0):
w = TAU * f / sr
alpha = sin(w) / (2 * q)
cw = math.cos(w)
a0 = 1 + alpha
b0, b2 = alpha / a0, -alpha / a0
a1, a2 = -2 * cw / a0, (1 - alpha) / a0
out = [0.0] * len(src)
x1 = x2 = y1 = y2 = 0.0
for i, x in enumerate(src):
y = b0 * x + b2 * x2 - a1 * y1 - a2 * y2
x2, x1 = x1, x
y2, y1 = y1, y
out[i] = y * gain
return out
def voice(seed, dur, pitch, env, formants, breath=0.25, rasp=0.5, rasp_hz=28.0, jitter=0.04):
"""pitch(q) and env(q) for q in 0..1; formants: [(Hz, Q, gain)]."""
rng = random.Random(seed)
sr = SR_FX
n = int(dur * sr)
src = [0.0] * n
ph = 0.0
wob = 0.0
nz = 0.0
for j in range(n):
q = j / n
wob += (rng.random() * 2 - 1) * 0.02 - wob * 0.002
f = pitch(q) * (1 + jitter * wob)
ph += f / sr
ph -= int(ph)
saw = 2 * ph - 1
nz += 0.5 * ((rng.random() * 2 - 1) - nz)
flutter = 1 - rasp * (0.5 + 0.5 * sin(TAU * rasp_hz * j / sr + 3 * nz))
src[j] = (saw * flutter + breath * nz) * env(q)
out = [0.0] * n
for f, qq, g in formants:
bp = bandpass(src, sr, f, qq, g)
for j in range(n):
out[j] += bp[j]
return out
def _swell(a, d, power=1.0):
"""Rise over the first a of the sound, fall over the last d."""
return lambda q: (min(1.0, q / a) * min(1.0, (1 - q) / d)) ** power
def make_voices():
d = os.path.join(OUT, "sfx", "creatures")
print("creature voices ->", os.path.relpath(d, ROOT))
W = [(420, 4, 1.0), (1150, 5, 0.6), (2500, 6, 0.25)] # a wolf's throat
B = [(260, 3, 1.0), (720, 4, 0.7), (1700, 5, 0.25)] # a bear's, lower and wider
for i in range(3):
base = 72 + 9 * i
_encode(os.path.join(d, "wolf_growl_%d.ogg" % i),
[voice(100 + i, 1.1 + 0.2 * i, lambda q, b=base: b * (1 + 0.15 * sin(TAU * 1.3 * q)),
_swell(0.25, 0.3), W, breath=0.45, rasp=0.75, rasp_hz=24 + 3 * i)], SR_FX, 5)
for i in range(2):
_encode(os.path.join(d, "wolf_snarl_%d.ogg" % i),
[voice(110 + i, 0.45, lambda q, k=i: 150 + 90 * k + 160 * sin(math.pi * q),
_swell(0.08, 0.4), W, breath=0.6, rasp=0.6, rasp_hz=38)], SR_FX, 5)
_encode(os.path.join(d, "wolf_yelp_%d.ogg" % i),
[voice(120 + i, 0.32, lambda q, k=i: 750 + 120 * k + 650 * sin(math.pi * min(1, q * 1.4)),
_swell(0.05, 0.6), [(900, 3, 1.0), (2200, 4, 0.5)], breath=0.15, rasp=0.15, jitter=0.02)], SR_FX, 5)
_encode(os.path.join(d, "bear_roar_0.ogg"),
[voice(130, 1.7, lambda q: 58 + 30 * sin(math.pi * q ** 0.7), _swell(0.2, 0.35), B,
breath=0.7, rasp=0.8, rasp_hz=21)], SR_FX, 5)
_encode(os.path.join(d, "bear_roar_1.ogg"),
[voice(131, 1.3, lambda q: 64 + 24 * sin(math.pi * q), _swell(0.12, 0.4), B,
breath=0.75, rasp=0.85, rasp_hz=19)], SR_FX, 5)
for i in range(2):
_encode(os.path.join(d, "bear_grunt_%d.ogg" % i),
[voice(140 + i, 0.45, lambda q, k=i: 70 + 12 * k - 15 * q, _swell(0.1, 0.5), B,
breath=0.5, rasp=0.7, rasp_hz=17)], SR_FX, 5)
P = [(380, 3, 1.0), (950, 4, 0.6), (2100, 5, 0.3)] # a boar's snout
for i in range(2):
pulses = [0.0, 0.16, 0.32][: 2 + i]
def env(q, ps=pulses):
t = q * 0.55
return max(max(0.0, 1 - abs(t - p - 0.05) / 0.06) for p in ps)
_encode(os.path.join(d, "boar_grunt_%d.ogg" % i),
[voice(150 + i, 0.55, lambda q: 95 + 10 * sin(TAU * 3 * q), env, P, breath=0.6, rasp=0.6, rasp_hz=30)], SR_FX, 5)
_encode(os.path.join(d, "boar_squeal_0.ogg"),
[voice(160, 0.6, lambda q: 650 + 300 * sin(math.pi * q) + 40 * sin(TAU * 9 * q), _swell(0.08, 0.35),
[(1200, 3, 1.0), (2600, 4, 0.5)], breath=0.3, rasp=0.35, rasp_hz=45)], SR_FX, 5)
S = [(520, 4, 1.0), (1450, 5, 0.7), (2900, 6, 0.3)] # a stag's long neck
for i in range(2):
_encode(os.path.join(d, "stag_bellow_%d.ogg" % i),
[voice(170 + i, 1.9 - 0.3 * i, lambda q, k=i: 105 + 8 * k + 55 * sin(math.pi * q ** 0.6), _swell(0.18, 0.3), S,
breath=0.55, rasp=0.65, rasp_hz=26)], SR_FX, 5)
sn = fx_buf(0.4)
add_noise(sn, SR_FX, 0.0, 0.32, 1.0, lp=0.18, hp=0.02, env=lambda q: min(1, q * 25) * exp(-q * 5))
_encode(os.path.join(d, "stag_snort_0.ogg"), [sn], SR_FX, 5)
for i in range(2):
rng = random.Random(180 + i)
rb = fx_buf(0.6)
t = 0.0
while t < 0.5:
i0 = int(t * SR_FX)
f = rng.uniform(1300, 2300)
for j in range(int(0.02 * SR_FX)):
if i0 + j < len(rb):
rb[i0 + j] += sin(TAU * f * j / SR_FX) * exp(-j / 130) * rng.uniform(0.5, 1.0)
add_noise(rb, SR_FX, t, 0.006, 0.6, lp=0.9, hp=0.3, env=lambda q: 1 - q, rng=rng)
t += rng.uniform(0.025, 0.06)
_encode(os.path.join(d, "guard_rattle_%d.ogg" % i), [rb], SR_FX, 5)
for i in range(2):
caws = [0.0, 0.33][: 1 + i]
def cenv(q, cs=caws, dur=0.32 + 0.33 * i):
t = q * dur
return max(max(0.0, min(1, (t - c) / 0.02)) * max(0.0, 1 - (t - c) / 0.24) if t >= c else 0.0 for c in cs)
_encode(os.path.join(d, "crow_caw_%d.ogg" % i),
[voice(190 + i, 0.32 + 0.33 * i, lambda q: 520 - 80 * q, cenv, [(1300, 3, 1.0), (2400, 4, 0.6)],
breath=0.5, rasp=0.5, rasp_hz=60)], SR_FX, 5)
for i in range(2):
_encode(os.path.join(d, "fox_yip_%d.ogg" % i),
[voice(200 + i, 0.18, lambda q, k=i: 800 + 80 * k + 500 * sin(math.pi * q), _swell(0.05, 0.5),
[(1100, 3, 1.0), (2600, 4, 0.4)], breath=0.2, rasp=0.2, jitter=0.02)], SR_FX, 5)
def make_sfx():
d = os.path.join(OUT, "sfx", "synth")
print("sound effects ->", os.path.relpath(d, ROOT))
for i in range(3):
_encode(os.path.join(d, "swing_%d.ogg" % i), [fx_whoosh(10 + i, secs=0.26 + 0.04 * i)], SR_FX, 5)
_encode(os.path.join(d, "eat_%d.ogg" % i), [fx_crunch(20 + i)], SR_FX, 5)
for i in range(2):
_encode(os.path.join(d, "splash_%d.ogg" % i), [fx_splash(30 + i)], SR_FX, 5)
_encode(os.path.join(d, "thump_%d.ogg" % i), [fx_thump(40 + i)], SR_FX, 5)
_encode(os.path.join(d, "splash_big.ogg"), [fx_splash(35, 1.6)], SR_FX, 5)
_encode(os.path.join(d, "plop.ogg"), [fx_plop()], SR_FX, 5)
_encode(os.path.join(d, "reel.ogg"), [fx_reel()], SR_FX, 5)
_encode(os.path.join(d, "cast.ogg"), [fx_cast()], SR_FX, 5)
_encode(os.path.join(d, "bow.ogg"), [fx_bow()], SR_FX, 5)
_encode(os.path.join(d, "death.ogg"), [fx_death()], SR_FX, 5)
def make_ambience():
d = os.path.join(OUT, "ambience")
print("ambience ->", os.path.relpath(d, ROOT))
for name, fn in (("fire", amb_fire),):
sr, ch = fn()
_encode(os.path.join(d, name + ".ogg"), list(ch), sr, 3)
for name in RECORDED:
make_recorded(name)
SOURCES = os.path.join(ROOT, "tools", "audio", "sources")
def sources(prefix):
"""The CC0 recordings a loop is made from (credited in assets/audio/CREDITS.md)."""
if not os.path.isdir(SOURCES):
return []
return sorted(os.path.join(SOURCES, f) for f in os.listdir(SOURCES) if f.startswith(prefix + "_"))
## The field-recorded loops: name -> (tone filter, which stretch of each source).
## Birds: the shrillest top notes pulled back a little (a dawn chorus is bright).
## Crickets: the rumble under them (wind, a distant road) taken out.
RECORDED = {
"birds": ("highshelf=f=3800:g=-5,lowpass=f=8500", None),
"crickets": ("highpass=f=300,highshelf=f=6000:g=-3", None),
}
def _decode(src, sr, af=None):
cmd = ["ffmpeg", "-v", "error", "-i", src] + (["-af", af] if af else []) + \
["-f", "s16le", "-ac", "2", "-ar", str(sr), "-"]
raw = subprocess.run(cmd, capture_output=True, check=True).stdout
n = len(raw) // 4
left = [struct.unpack_from("<h", raw, i * 4)[0] / 32768.0 for i in range(n)]
right = [struct.unpack_from("<h", raw, i * 4 + 2)[0] / 32768.0 for i in range(n)]
# Each to the same loudness (RMS), so the loop doesn't swell and fade.
rms = math.sqrt(sum(x * x for x in left + right) / (2 * n)) or 1.0
return [x * 0.06 / rms for x in left], [x * 0.06 / rms for x in right]
def make_recorded(name, srcs=None):
"""A field-recorded ambience loop (assets/audio/ambience/<name>.ogg) from
CC0 recordings (tools/audio/sources/<name>_*, or the files given): each
evened out to the same loudness, chained one into the next and the last
back into the first with 3 s equal-power crossfades, into one seamless
loop. (Synthesised birdsong sounded like beeping, and crickets like a
smoke alarm.)"""
tone, _ = RECORDED[name]
srcs = srcs or sources(name)
if not srcs:
sys.exit("no recordings for %s in %s" % (name, os.path.relpath(SOURCES, ROOT)))
sr, xf = 44100, 3.0
k = int(xf * sr)
clips = [_decode(s, sr, tone) for s in srcs]
out_l, out_r = [], []
for c, (left, right) in enumerate(clips):
if c == 0:
out_l, out_r = list(left), list(right)
continue
# The end of what we have fades out over the start of the next clip.
base = len(out_l) - k
for i in range(k):
a = math.sin(0.5 * math.pi * i / k)
b = math.cos(0.5 * math.pi * i / k)
out_l[base + i] = out_l[base + i] * b + left[i] * a
out_r[base + i] = out_r[base + i] * b + right[i] * a
out_l += left[k:]
out_r += right[k:]
# And the end back into the beginning, so it loops.
L = len(out_l) - k
for i in range(k):
a = math.sin(0.5 * math.pi * i / k)
b = math.cos(0.5 * math.pi * i / k)
out_l[i] = out_l[i] * a + out_l[L + i] * b
out_r[i] = out_r[i] * a + out_r[L + i] * b
_encode(os.path.join(OUT, "ambience", name + ".ogg"), [out_l[:L], out_r[:L]], sr, 4)
def make_music():
d = os.path.join(OUT, "music")
print("music ->", os.path.relpath(d, ROOT))
for name, fn in (("calm", track_calm), ("night", track_night), ("storm", track_storm),
("danger", track_danger), ("boss", track_boss)):
sr, ch = fn()
_encode(os.path.join(d, name + ".ogg"), list(ch), sr, 4)
# --- the Kenney CC0 packs (copied once, renamed; see assets/audio/CREDITS.md) ----------
KENNEY = {
# ours: (pack, file in its Audio/ folder)
"chop": [("impact-sounds", "impactWood_medium_%03d.ogg" % i) for i in range(5)],
"wood_heavy": [("impact-sounds", "impactWood_heavy_%03d.ogg" % i) for i in range(5)],
"wood_light": [("impact-sounds", "impactWood_light_%03d.ogg" % i) for i in range(5)],
"mine": [("impact-sounds", "impactMining_%03d.ogg" % i) for i in range(5)],
"plank": [("impact-sounds", "impactPlank_medium_%03d.ogg" % i) for i in range(5)],
"punch": [("impact-sounds", "impactPunch_medium_%03d.ogg" % i) for i in range(5)],
"punch_heavy": [("impact-sounds", "impactPunch_heavy_%03d.ogg" % i) for i in range(5)],
"soft_heavy": [("impact-sounds", "impactSoft_heavy_%03d.ogg" % i) for i in range(5)],
"step_grass": [("impact-sounds", "footstep_grass_%03d.ogg" % i) for i in range(5)],
"step_snow": [("impact-sounds", "footstep_snow_%03d.ogg" % i) for i in range(5)],
"step_wood": [("impact-sounds", "footstep_wood_%03d.ogg" % i) for i in range(5)],
"step_stone": [("impact-sounds", "footstep_concrete_%03d.ogg" % i) for i in range(5)],
"creak": [("rpg-audio", "creak%d.ogg" % i) for i in (1, 2, 3)],
"door_open": [("rpg-audio", "doorOpen_%d.ogg" % i) for i in (1, 2)],
"door_close": [("rpg-audio", "doorClose_%d.ogg" % i) for i in (1, 2, 3, 4)],
"cloth": [("rpg-audio", "cloth%d.ogg" % i) for i in (1, 2, 3, 4)],
"leather": [("rpg-audio", n) for n in ("handleSmallLeather.ogg", "handleSmallLeather2.ogg", "dropLeather.ogg")],
"knife": [("rpg-audio", n) for n in ("knifeSlice.ogg", "knifeSlice2.ogg")],
"metal_click": [("rpg-audio", n) for n in ("metalClick.ogg", "metalLatch.ogg")],
"ui_open": [("interface-sounds", "open_001.ogg")],
"ui_close": [("interface-sounds", "close_001.ogg")],
"ui_tick": [("interface-sounds", "tick_001.ogg")],
"ui_good": [("interface-sounds", "confirmation_001.ogg")],
"ui_step": [("interface-sounds", "confirmation_003.ogg")],
"ui_catch": [("interface-sounds", "confirmation_002.ogg")],
"ui_tip": [("interface-sounds", "question_001.ogg")],
}
def copy_kenney(src):
d = os.path.join(OUT, "sfx", "kenney")
os.makedirs(d, exist_ok=True)
by_name = {}
for root, _, files in os.walk(src):
pack = next((p for p in ("rpg-audio", "impact-sounds", "interface-sounds") if p in root), None)
for f in files:
if pack and f.endswith(".ogg"):
by_name[(pack, f)] = os.path.join(root, f)
n = 0
for ours, lst in KENNEY.items():
for i, key in enumerate(lst):
if key not in by_name:
sys.exit("missing from the Kenney packs: %s/%s" % key)
shutil.copyfile(by_name[key], os.path.join(d, "%s_%d.ogg" % (ours, i)))
n += 1
print("copied %d Kenney sounds -> %s" % (n, os.path.relpath(d, ROOT)))
if __name__ == "__main__":
args = sys.argv[1:]
if "--kenney" in args:
i = args.index("--kenney")
copy_kenney(os.path.expanduser(args[i + 1]))
del args[i:i + 2]
what = args[0] if args else "all"
if what in RECORDED: # one recorded loop: birds | crickets
make_recorded(what)
sys.exit(0)
if what in ("all", "sfx"):
make_sfx()
if what in ("all", "sfx", "voices"):
make_voices()
if what in ("all", "ambience"):
make_ambience()
if what in ("all", "music"):
make_music()