diff options
Diffstat (limited to 'tools/sounds')
| -rw-r--r-- | tools/sounds/build-sounds.py | 107 |
1 files changed, 107 insertions, 0 deletions
diff --git a/tools/sounds/build-sounds.py b/tools/sounds/build-sounds.py new file mode 100644 index 0000000..0b64aeb --- /dev/null +++ b/tools/sounds/build-sounds.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# Cuts the night sounds in public/assets/sounds/ out of their source +# recordings: trims them, cleans up the owls, makes the fire and wind loop +# seamlessly, and encodes them as Ogg Opus (gapless when looped). +# +# python3 tools/sounds/build-sounds.py SOURCE_DIR public/assets/sounds +# +# SOURCE_DIR holds the downloads under these names. BigSoundBank files are +# https://bigsoundbank.com/UPLOAD/bwf-en/<number>.wav (CC0, Joseph Sardin). +# 0988.wav "Big branching fire #2" bigsoundbank.com/big-branching-fire-2-s0988.html +# 0904.wav "Wind in the Trees" bigsoundbank.com/wind-in-the-trees-s0904.html +# 1299.wav "Broken twigs #1" bigsoundbank.com/broken-twigs-1-s1299.html +# 1301.wav "Steps on the Twigs" bigsoundbank.com/steps-on-the-twigs-s1301.html +# 0137.wav "Feet in Leaves #1" bigsoundbank.com/feet-in-leaves-1-s0137.html +# 1588.wav "Gavel, 1 blow" bigsoundbank.com/gavel-1-blow-s1588.html +# 0581.wav "Two dice on wooden table" bigsoundbank.com/two-dice-on-wooden-table-s0581.html +# gho.mp3 "Bubo virginianus - Great Horned Owl XC450919.mp3" by Michael & +# Katie LaTour (xeno-canto), CC BY-SA 4.0, on Wikimedia Commons +# bdow.oga "Barred Owl, Yellowstone National Park.oga", National Park +# Service, public domain, on Wikimedia Commons +# +# Needs ffmpeg (with libopus) and numpy. +import os +import subprocess +import sys + +import numpy as np + +RATE = 48000 + + +def load(path, start=0.0, length=None, filters=None, channels=1): + cmd = ['ffmpeg', '-v', 'error', '-ss', str(start)] + if length: + cmd += ['-t', str(length)] + cmd += ['-i', path, '-ac', str(channels), '-ar', str(RATE)] + if filters: + cmd += ['-af', filters] + raw = subprocess.run(cmd + ['-f', 'f32le', '-'], capture_output=True, check=True).stdout + return np.frombuffer(raw, np.float32).reshape(-1, channels).copy() + + +def save(audio, path, bitrate='64k'): + audio = audio / np.abs(audio).max() * 0.89 # peak -1 dB; loudness is set in ambience.js + channels = audio.shape[1] + subprocess.run( + ['ffmpeg', '-v', 'error', '-y', '-f', 'f32le', '-ar', str(RATE), '-ac', str(channels), '-i', '-', + '-c:a', 'libopus', '-b:a', bitrate, path], + input=audio.astype(np.float32).tobytes(), check=True) + print(f'{os.path.basename(path)}: {len(audio) / RATE:.1f} s, {os.path.getsize(path) // 1024} KB') + + +def fade(audio, fade_in=0.05, fade_out=0.3): + n_in, n_out = int(fade_in * RATE), int(fade_out * RATE) + audio[:n_in] *= np.linspace(0, 1, n_in)[:, None] + audio[-n_out:] *= np.linspace(1, 0, n_out)[:, None] + return audio + + +def loop(audio, overlap): + """Folds the last `overlap` seconds over the start with an equal-power crossfade.""" + n = int(overlap * RATE) + body = audio[:-n].copy() + t = np.linspace(0, 1, n)[:, None] + body[:n] = body[:n] * np.sqrt(t) + audio[-n:] * np.sqrt(1 - t) + return body + + +def main(src, dst): + os.makedirs(dst, exist_ok=True) + s = lambda name: os.path.join(src, name) + d = lambda name: os.path.join(dst, name) + + # Fire: 40 s of an outdoor branch fire, looped, without the rumble under it + # (around 20 Hz: the microphone or distant traffic, not the fire). + save(loop(load(s('0988.wav'), 4, 42, 'highpass=f=70:poles=2,highpass=f=70:poles=2'), 2.0), d('fire.ogg')) + + # Wind in the trees: stereo, looped. Under the rustling there's a steady roar + # of distant traffic or planes (it doesn't follow the gusts), so only the + # leaves are kept. (From 33 s: earlier there's a bump, later a few birds.) + save(loop(load(s('0904.wav'), 33, 63, 'highpass=f=500:poles=2,highpass=f=500:poles=2', channels=2), 3.0), + d('wind.ogg'), '80k') + + # Owls: two phrases of the great horned owl, with the low hum under them taken out. + owl = 'highpass=f=250:poles=2,highpass=f=250:poles=2,lowpass=f=1400' + save(fade(load(s('gho.mp3'), 2.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-1.ogg'), '48k') + save(fade(load(s('gho.mp3'), 10.0, 3.6, owl), 0.15, 0.4), d('owl-great-horned-2.ogg'), '48k') + save(fade(load(s('bdow.oga'), 0, None, 'highpass=f=200:poles=2,highpass=f=200:poles=2'), 0.05, 0.4), d('owl-barred.ogg'), '48k') + + # Something out in the dark: twigs snapping, and footsteps on twigs and in + # leaves. The footsteps were recorded close up and thud (mostly below 40 Hz), + # which something small walking past 20 m away wouldn't. + light = 'highpass=f=150:poles=2,highpass=f=150:poles=2' + save(fade(load(s('1299.wav'), 3.55, 0.75, light)), d('twig-1.ogg')) + save(fade(load(s('1299.wav'), 0.0, 2.3, light)), d('twig-2.ogg')) + save(fade(load(s('1301.wav'), 0.0, 6.4, light), 0.05, 0.5), d('steps-twigs.ogg')) + save(fade(load(s('0137.wav'), 0.0, 7.8, light), 0.05, 0.6), d('steps-leaves.ogg')) + + # A chess piece set down (chess.js): a light gavel tap, and the first knock of + # dice landing on a wooden table, cut before they bounce. + save(fade(load(s('1588.wav'), 0.02, 0.25, light), 0.003, 0.15), d('chess-1.ogg'), '48k') + save(fade(load(s('0581.wav'), 4.095, 0.17, light), 0.003, 0.08), d('chess-2.ogg'), '48k') + save(fade(load(s('0581.wav'), 12.825, 0.13, light), 0.003, 0.06), d('chess-3.ogg'), '48k') + + +if __name__ == '__main__': + main(sys.argv[1], sys.argv[2]) |