#!/usr/bin/env python3 """Sound effects for the web build of Ardentryst, made by us (CC0 1.0: no rights reserved). The original effects were partly recorded by other people (freesound.org users) under licences the game does not record file by file, so the web build leaves them out and plays these instead: simple synthesised sounds (sine and noise, shaped by envelopes and filters) under the same file names, so that the game finds them where it expects them. python3 -I gen-sounds.py Deterministic: the noise comes from a fixed linear congruential generator, so the same bytes come out every time (the wav2ogg tool, which wants stereo, writes Ogg Vorbis with a fixed stream serial number). """ import math import os import struct import subprocess import sys import tempfile import wave RATE = 22050 class Rng: def __init__(self, seed): self.s = seed & 0xFFFFFFFF def noise(self): self.s = (1664525 * self.s + 1013904223) & 0xFFFFFFFF return (self.s / 2147483648.0) - 1.0 def n_samples(sec): return max(1, int(RATE * sec)) def env_ad(i, n, attack=0.01, power=2.0): """Quick attack, then a fall to zero over the rest of the sound.""" t = i / n a = attack if t < a: return t / a return (1.0 - (t - a) / (1.0 - a)) ** power def lowpass(x, cutoff): k = 1.0 - math.exp(-2 * math.pi * cutoff / RATE) y, out = 0.0, [] for v in x: y += k * (v - y) out.append(y) return out def highpass(x, cutoff): low = lowpass(x, cutoff) return [a - b for a, b in zip(x, low)] def bandpass(x, lo, hi): return highpass(lowpass(x, hi), lo) def noise(sec, seed): r = Rng(seed) return [r.noise() for _ in range(n_samples(sec))] def tone(freq, sec, vib=0.0, harmonics=((1, 1.0),), glide=None): """A tone; freq may glide to glide[0] (Hz) over the sound.""" n = n_samples(sec) out, ph = [], 0.0 for i in range(n): f = freq if glide is None else freq + (glide - freq) * (i / n) if vib: f *= 1.0 + vib * math.sin(2 * math.pi * 6.0 * i / RATE) ph += 2 * math.pi * f / RATE out.append(sum(a * math.sin(h * ph) for h, a in harmonics)) return out def shaped(x, attack=0.01, power=2.0): n = len(x) return [v * env_ad(i, n, attack, power) for i, v in enumerate(x)] def mix(*parts): n = max(len(p) for p in parts) out = [0.0] * n for p in parts: for i, v in enumerate(p): out[i] += v return out def delay_to(x, offset_sec, total_sec=None): off = n_samples(offset_sec) if offset_sec > 0 else 0 out = [0.0] * off + list(x) if total_sec: n = n_samples(total_sec) out = (out + [0.0] * n)[:n] return out def gain(x, g): return [v * g for v in x] def normalise(x, peak=0.8): m = max(abs(v) for v in x) or 1.0 return [v * peak / m for v in x] def fade_edges(x, ms=8): n = n_samples(ms / 1000.0) out = list(x) for i in range(min(n, len(out))): f = i / n out[i] *= f out[-1 - i] *= f return out def loopable(x, ms=150): """Cross-fades the end into the start so that the sound loops without a click.""" n = min(n_samples(ms / 1000.0), len(x) // 2) out = list(x[: len(x) - n]) for i in range(n): f = i / n out[i] = out[i] * f + x[len(x) - n + i] * (1 - f) return out def chime(freq, sec, bright=0.4): return shaped(tone(freq, sec, harmonics=((1, 1.0), (2.76, bright * 0.5), (5.4, bright * 0.25))), 0.005, 2.5) def arpeggio(freqs, step, sec_each, bright=0.4): return mix(*[delay_to(chime(f, sec_each, bright), step * i) for i, f in enumerate(freqs)]) def thud(sec, freq=70, seed=1, grit=0.4): n = tone(freq, sec, glide=freq * 0.5) r = lowpass(noise(sec, seed), 600) return shaped(mix(n, gain(r, grit * 3)), 0.002, 3.0) def swish(sec, lo, hi, seed, peak=0.4): n = len(noise(sec, seed)) b = bandpass(noise(sec, seed), lo, hi) out = [] for i, v in enumerate(b): t = i / n e = math.sin(math.pi * min(1.0, t / (peak * 2))) if t < peak else (1.0 - (t - peak) / (1.0 - peak)) ** 1.5 out.append(v * e) return out def voice(freq, sec, glide, seed, rough=0.3, formant=1.0): """A creature cry: a gliding buzzy tone with some noise.""" n = n_samples(sec) t = tone(freq, sec, vib=0.03, glide=glide, harmonics=((1, 1.0), (2, 0.6), (3, 0.4 * formant), (5, 0.2 * formant))) r = bandpass(noise(sec, seed), 300, 2500) return shaped([a + rough * b for a, b in zip(t, r)], 0.03, 1.5) def drone(sec, base, seed): n = n_samples(sec) parts = [tone(base, sec, vib=0.002), tone(base * 1.5, sec, vib=0.003), tone(base * 2.01, sec, vib=0.002)] wind = lowpass(noise(sec, seed), 400) out = mix(*[gain(p, 0.25) for p in parts], gain(wind, 0.6)) # slow swell return [v * (0.55 + 0.45 * math.sin(2 * math.pi * 0.07 * i / RATE)) for i, v in enumerate(out)] # --------------------------------------------------------------------------------------------------------------- # the sounds, by file name (seconds are those of the original files, so that scenes keep their timing) # --------------------------------------------------------------------------------------------------------------- def make(name): base = os.path.splitext(os.path.basename(name))[0] folder = os.path.dirname(name) if folder.startswith("MonsterSounds"): return monster(folder.split("/")[1], base) seed = sum(ord(c) * (i + 1) for i, c in enumerate(base)) + 17 f = RECIPES[base] return f(seed) def monster(kind, base): seed = sum(ord(c) * (i + 3) for i, c in enumerate(kind + base)) + 5 pitch = {"cspider": 1500, "spider": 1300, "snowbat": 2200, "wasp": 900, "worm": 260, "giantworm": 110, "pod": 380, "poispod": 330, "giantjelly": 200, "snowogre": 95}[kind] which = base.rstrip("0123456789") idx = int(base[len(which):] or 1) sec = DURATIONS["MonsterSounds/%s/%s.ogg" % (kind, base)] p = pitch * (1.0 + 0.12 * (idx - 1)) if which == "squeak": return voice(p, sec, p * 1.7, seed, 0.15, 0.6) if which == "hurt": return voice(p * 1.2, sec, p * 0.6, seed, 0.35) if which in ("dead", "death"): return voice(p * 1.1, sec, p * 0.25, seed, 0.4) if which == "belch": return voice(p * 0.8, sec, p * 0.5, seed, 0.8, 0.3) if which == "collide": return thud(sec, 90, seed, 0.6) if which == "barb": return mix(voice(p * 1.5, sec * 0.4, p * 2.2, seed, 0.2), delay_to(swish(sec * 0.5, 1500, 5000, seed + 1), 0.15, sec)) return voice(p, sec, p * 0.7, seed) DURATIONS = { "AMB_Intro_1.ogg": 40.53, "AMB_Intro_2.ogg": 9.52, "AMB_Intro_3.ogg": 35.86, "AMB_Intro_4.ogg": 29.97, "AMB_Intro_5.ogg": 26.71, "AMB_Intro_6.ogg": 31.09, "Burst.ogg": 1.5, "ChimeGliss.ogg": 1.13, "Cut.ogg": 0.38, "Cut2.ogg": 0.31, "Cut3.ogg": 0.3, "Hit.ogg": 0.37, "HitSlice.ogg": 0.34, "Ice.ogg": 0.62, "Implosion.ogg": 1.33, "Maea.ogg": 6.56, "Magicpower.ogg": 1.68, "MonsterSounds/cspider/squeak1.ogg": 0.35, "MonsterSounds/cspider/squeak2.ogg": 0.35, "MonsterSounds/giantjelly/collide1.ogg": 0.84, "MonsterSounds/giantjelly/hurt1.ogg": 0.63, "MonsterSounds/giantworm/belch1.ogg": 0.98, "MonsterSounds/giantworm/collide1.ogg": 0.56, "MonsterSounds/giantworm/dead1.ogg": 3.49, "MonsterSounds/giantworm/hurt1.ogg": 1.66, "MonsterSounds/giantworm/hurt2.ogg": 1.02, "MonsterSounds/pod/barb1.ogg": 0.89, "MonsterSounds/pod/death1.ogg": 1.89, "MonsterSounds/pod/death2.ogg": 0.82, "MonsterSounds/pod/hurt1.ogg": 0.93, "MonsterSounds/pod/hurt2.ogg": 1.11, "MonsterSounds/pod/hurt3.ogg": 1.11, "MonsterSounds/poispod/barb1.ogg": 0.89, "MonsterSounds/poispod/death1.ogg": 1.89, "MonsterSounds/poispod/death2.ogg": 0.82, "MonsterSounds/poispod/hurt1.ogg": 0.93, "MonsterSounds/poispod/hurt2.ogg": 1.11, "MonsterSounds/poispod/hurt3.ogg": 1.11, "MonsterSounds/snowbat/squeak1.ogg": 0.71, "MonsterSounds/snowogre/hurt1.ogg": 0.63, "MonsterSounds/spider/squeak1.ogg": 0.35, "MonsterSounds/spider/squeak2.ogg": 0.35, "MonsterSounds/wasp/dead1.ogg": 1.06, "MonsterSounds/wasp/hurt1.ogg": 0.66, "MonsterSounds/worm/collide1.ogg": 0.84, "MonsterSounds/worm/hurt1.ogg": 0.63, "MonsterSounds/worm/hurt2.ogg": 0.51, "Msg.ogg": 1.92, "Obelisk.ogg": 4.97, "Rain_Loop.ogg": 20.17, "Rock.ogg": 0.42, "Treasure.ogg": 1.91, "WeaponSwing.ogg": 0.24, "WeaponSwingHigh.ogg": 0.21, "WoodHit.ogg": 0.1, "cavewind.ogg": 1.22, "drink.ogg": 1.49, "equip.ogg": 0.65, "fail.ogg": 0.29, "fire_loop.ogg": 9.32, "flame_whoosh.ogg": 2.27, "magicbow.ogg": 0.2, "mdrain.ogg": 0.22, "menu-back.ogg": 1.23, "menu-item.ogg": 0.14, "menu-select.ogg": 1.27, "menu-small-select.ogg": 0.57, "newgame.ogg": 5.64, "orb.ogg": 0.59, "quake.ogg": 2.4, "stone.ogg": 0.51, } def d(name): return DURATIONS[name + ".ogg"] RECIPES = { "AMB_Intro_1": lambda s: drone(d("AMB_Intro_1"), 55, s), "AMB_Intro_2": lambda s: drone(d("AMB_Intro_2"), 62, s), "AMB_Intro_3": lambda s: drone(d("AMB_Intro_3"), 49, s), "AMB_Intro_4": lambda s: drone(d("AMB_Intro_4"), 58, s), "AMB_Intro_5": lambda s: drone(d("AMB_Intro_5"), 52, s), "AMB_Intro_6": lambda s: drone(d("AMB_Intro_6"), 65, s), "Burst": lambda s: mix(shaped(lowpass(noise(d("Burst"), s), 3000), 0.005, 2.0), shaped(tone(180, d("Burst"), glide=40), 0.005, 2.5)), "ChimeGliss": lambda s: arpeggio([784, 988, 1175, 1319, 1568, 1976, 2349], 0.09, 0.5), "Cut": lambda s: mix(swish(d("Cut"), 1200, 6000, s), thud(0.12, 140, s + 1, 0.3)), "Cut2": lambda s: mix(swish(d("Cut2"), 900, 5000, s), thud(0.1, 160, s + 1, 0.3)), "Cut3": lambda s: mix(swish(d("Cut3"), 1500, 7000, s), thud(0.1, 130, s + 1, 0.3)), "Hit": lambda s: thud(d("Hit"), 85, s, 0.5), "HitSlice": lambda s: mix(swish(d("HitSlice"), 1000, 6500, s, 0.25), thud(0.12, 150, s + 1, 0.4)), "Ice": lambda s: mix(arpeggio([2093, 2637, 3136, 3951], 0.05, 0.4, 0.8), shaped(highpass(noise(d("Ice"), s), 4000), 0.01, 2.0)), "Implosion": lambda s: mix(shaped(tone(900, d("Implosion"), glide=45), 0.01, 1.5), delay_to(thud(0.5, 60, s, 0.8), 0.8, d("Implosion"))), "Maea": lambda s: mix(shaped(lowpass(noise(d("Maea"), s), 500), 0.1, 1.2), gain(shaped(tone(70, d("Maea"), vib=0.05, glide=40, harmonics=((1, 1), (2, 0.5), (3, 0.3))), 0.15, 1.0), 0.7), delay_to(swish(2.5, 200, 1800, s + 2, 0.5), 1.5, d("Maea"))), "Magicpower": lambda s: mix(shaped(tone(300, d("Magicpower"), glide=1800, harmonics=((1, 1), (2, 0.3))), 0.1, 1.0), shaped(highpass(noise(d("Magicpower"), s), 3000), 0.3, 1.0)), "Msg": lambda s: arpeggio([659, 880, 1047, 1319], 0.18, 1.2), "Obelisk": lambda s: mix(shaped(tone(82, d("Obelisk"), vib=0.01, harmonics=((1, 1), (2, 0.4), (3, 0.2))), 0.2, 1.0), delay_to(chime(440, 3.0), 0.5, d("Obelisk")), delay_to(chime(659, 3.0), 1.2, d("Obelisk"))), "Rain_Loop": lambda s: loopable(mix(gain(highpass(lowpass(noise(6.0, s), 6000), 800), 0.8), gain(lowpass(noise(6.0, s + 1), 300), 0.4))), "Rock": lambda s: thud(d("Rock"), 75, s, 0.9), "Treasure": lambda s: arpeggio([523, 659, 784, 1047, 1319, 1568], 0.12, 1.0), "WeaponSwing": lambda s: swish(d("WeaponSwing"), 600, 4000, s), "WeaponSwingHigh": lambda s: swish(d("WeaponSwingHigh"), 900, 5500, s), "WoodHit": lambda s: thud(d("WoodHit"), 220, s, 0.3), "cavewind": lambda s: shaped(bandpass(noise(d("cavewind"), s), 150, 900), 0.2, 1.0), "drink": lambda s: mix(*[delay_to(shaped(tone(300 + 60 * i, 0.14, glide=520 + 60 * i), 0.1, 1.5), 0.2 * i, d("drink")) for i in range(5)]), "equip": lambda s: mix(chime(1760, d("equip"), 1.0), thud(0.08, 300, s, 0.2)), "fail": lambda s: shaped(tone(140, d("fail"), harmonics=((1, 1), (2, 0.5), (3, 0.33), (4, 0.25))), 0.01, 1.2), "fire_loop": lambda s: loopable(mix(gain(lowpass(noise(5.0, s), 900), 1.5), gain(highpass(noise(5.0, s + 1), 3500), 0.15))), "flame_whoosh": lambda s: swish(d("flame_whoosh"), 150, 2500, s, 0.35), "magicbow": lambda s: shaped(tone(1100, d("magicbow"), glide=300), 0.005, 2.5), "mdrain": lambda s: shaped(tone(900, d("mdrain"), glide=250), 0.01, 1.5), "menu-back": lambda s: arpeggio([880, 659, 523], 0.16, 1.0, 0.3), "menu-item": lambda s: shaped(tone(1320, d("menu-item")), 0.02, 2.0), "menu-select": lambda s: arpeggio([659, 988], 0.14, 1.1, 0.3), "menu-small-select": lambda s: arpeggio([880, 1175], 0.08, 0.5, 0.3), "newgame": lambda s: mix(shaped(tone(110, d("newgame"), harmonics=((1, 1), (2, 0.4), (3, 0.3))), 0.2, 1.0), arpeggio([220, 330, 440, 659, 880], 0.5, 2.5)), "orb": lambda s: mix(shaped(tone(700, d("orb"), glide=2400), 0.05, 1.5), delay_to(chime(2637, 0.4), 0.1, d("orb"))), "quake": lambda s: mix(shaped(lowpass(noise(d("quake"), s), 200), 0.05, 1.2), shaped(tone(40, d("quake"), vib=0.1), 0.05, 1.0)), "stone": lambda s: mix(thud(d("stone"), 60, s, 1.0), shaped(bandpass(noise(d("stone"), s + 3), 200, 1200), 0.05, 1.5)), } def main(): sounds_dir, wav2ogg = sys.argv[1:3] names = sorted(DURATIONS) for name in names: samples = fade_edges(normalise(make(name), 0.8)) out = os.path.join(sounds_dir, name) os.makedirs(os.path.dirname(out), exist_ok=True) with tempfile.TemporaryDirectory() as tmp: wav = os.path.join(tmp, "s.wav") with wave.open(wav, "wb") as w: w.setnchannels(2) w.setsampwidth(2) w.setframerate(RATE) w.writeframes(b"".join(struct.pack("